]> git.madduck.net Git - etc/taskwarrior.git/blob - tasklib/task.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

SerializingObject: Add normalizers for normalizing user input
[etc/taskwarrior.git] / tasklib / task.py
1 from __future__ import print_function
2 import copy
3 import datetime
4 import json
5 import logging
6 import os
7 import six
8 import sys
9 import subprocess
10
11 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
12 REPR_OUTPUT_SIZE = 10
13 PENDING = 'pending'
14 COMPLETED = 'completed'
15
16 VERSION_2_1_0 = six.u('2.1.0')
17 VERSION_2_2_0 = six.u('2.2.0')
18 VERSION_2_3_0 = six.u('2.3.0')
19 VERSION_2_4_0 = six.u('2.4.0')
20
21 logger = logging.getLogger(__name__)
22
23
24 class TaskWarriorException(Exception):
25     pass
26
27
28 class SerializingObject(object):
29     """
30     Common ancestor for TaskResource & TaskFilter, since they both
31     need to serialize arguments.
32
33     Serializing method should hold the following contract:
34       - any empty value (meaning removal of the attribute)
35         is deserialized into a empty string
36       - None denotes a empty value for any attribute
37
38     Deserializing method should hold the following contract:
39       - None denotes a empty value for any attribute (however,
40         this is here as a safeguard, TaskWarrior currently does
41         not export empty-valued attributes) if the attribute
42         is not iterable (e.g. list or set), in which case
43         a empty iterable should be used.
44     """
45
46     def _deserialize(self, key, value):
47         hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
48                                lambda x: x if x != '' else None)
49         return hydrate_func(value)
50
51     def _serialize(self, key, value):
52         dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
53                                  lambda x: x if x is not None else '')
54         return dehydrate_func(value)
55
56     def _normalize(self, key, value):
57         """
58         Use normalize_<key> methods to normalize user input. Any user
59         input will be normalized at the moment it is used as filter,
60         or entered as a value of Task attribute.
61         """
62
63         normalize_func = getattr(self, 'normalize_{0}'.format(key),
64                                  lambda x: x)
65
66         return normalize_func(value)
67
68     def timestamp_serializer(self, date):
69         if not date:
70             return ''
71         return date.strftime(DATE_FORMAT)
72
73     def timestamp_deserializer(self, date_str):
74         if not date_str:
75             return None
76         return datetime.datetime.strptime(date_str, DATE_FORMAT)
77
78     def serialize_entry(self, value):
79         return self.timestamp_serializer(value)
80
81     def deserialize_entry(self, value):
82         return self.timestamp_deserializer(value)
83
84     def serialize_modified(self, value):
85         return self.timestamp_serializer(value)
86
87     def deserialize_modified(self, value):
88         return self.timestamp_deserializer(value)
89
90     def serialize_due(self, value):
91         return self.timestamp_serializer(value)
92
93     def deserialize_due(self, value):
94         return self.timestamp_deserializer(value)
95
96     def serialize_scheduled(self, value):
97         return self.timestamp_serializer(value)
98
99     def deserialize_scheduled(self, value):
100         return self.timestamp_deserializer(value)
101
102     def serialize_until(self, value):
103         return self.timestamp_serializer(value)
104
105     def deserialize_until(self, value):
106         return self.timestamp_deserializer(value)
107
108     def serialize_wait(self, value):
109         return self.timestamp_serializer(value)
110
111     def deserialize_wait(self, value):
112         return self.timestamp_deserializer(value)
113
114     def serialize_annotations(self, value):
115         value = value if value is not None else []
116
117         # This may seem weird, but it's correct, we want to export
118         # a list of dicts as serialized value
119         serialized_annotations = [json.loads(annotation.export_data())
120                                   for annotation in value]
121         return serialized_annotations if serialized_annotations else ''
122
123     def deserialize_annotations(self, data):
124         return [TaskAnnotation(self, d) for d in data] if data else []
125
126     def serialize_tags(self, tags):
127         return ','.join(tags) if tags else ''
128
129     def deserialize_tags(self, tags):
130         if isinstance(tags, six.string_types):
131             return tags.split(',') if tags else []
132         return tags or []
133
134     def serialize_depends(self, value):
135         # Return the list of uuids
136         value = value if value is not None else set()
137         return ','.join(task['uuid'] for task in value)
138
139     def deserialize_depends(self, raw_uuids):
140         raw_uuids = raw_uuids or ''  # Convert None to empty string
141         uuids = raw_uuids.split(',')
142         return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
143
144
145 class TaskResource(SerializingObject):
146     read_only_fields = []
147
148     def _load_data(self, data):
149         self._data = dict((key, self._deserialize(key, value))
150                           for key, value in data.items())
151         # We need to use a copy for original data, so that changes
152         # are not propagated.
153         self._original_data = copy.deepcopy(self._data)
154
155     def _update_data(self, data, update_original=False):
156         """
157         Low level update of the internal _data dict. Data which are coming as
158         updates should already be serialized. If update_original is True, the
159         original_data dict is updated as well.
160         """
161         self._data.update(dict((key, self._deserialize(key, value))
162                                for key, value in data.items()))
163
164         if update_original:
165             self._original_data = copy.deepcopy(self._data)
166
167
168     def __getitem__(self, key):
169         # This is a workaround to make TaskResource non-iterable
170         # over simple index-based iteration
171         try:
172             int(key)
173             raise StopIteration
174         except ValueError:
175             pass
176
177         if key not in self._data:
178             self._data[key] = self._deserialize(key, None)
179
180         return self._data.get(key)
181
182     def __setitem__(self, key, value):
183         if key in self.read_only_fields:
184             raise RuntimeError('Field \'%s\' is read-only' % key)
185         self._data[key] = value
186
187     def __str__(self):
188         s = six.text_type(self.__unicode__())
189         if not six.PY3:
190             s = s.encode('utf-8')
191         return s
192
193     def __repr__(self):
194         return str(self)
195
196     def export_data(self):
197         """
198         Exports current data contained in the Task as JSON
199         """
200
201         # We need to remove spaces for TW-1504, use custom separators
202         data_tuples = ((key, self._serialize(key, value))
203                        for key, value in six.iteritems(self._data))
204
205         # Empty string denotes empty serialized value, we do not want
206         # to pass that to TaskWarrior.
207         data_tuples = filter(lambda t: t[1] is not '', data_tuples)
208         data = dict(data_tuples)
209         return json.dumps(data, separators=(',',':'))
210
211     @property
212     def _modified_fields(self):
213         writable_fields = set(self._data.keys()) - set(self.read_only_fields)
214         for key in writable_fields:
215             new_value = self._data.get(key)
216             old_value = self._original_data.get(key)
217
218             # Make sure not to mark data removal as modified field if the
219             # field originally had some empty value
220             if key in self._data and not new_value and not old_value:
221                 continue
222
223             if new_value != old_value:
224                 yield key
225
226     @property
227     def modified(self):
228         return bool(list(self._modified_fields))
229
230
231 class TaskAnnotation(TaskResource):
232     read_only_fields = ['entry', 'description']
233
234     def __init__(self, task, data={}):
235         self.task = task
236         self._load_data(data)
237
238     def remove(self):
239         self.task.remove_annotation(self)
240
241     def __unicode__(self):
242         return self['description']
243
244     def __eq__(self, other):
245         # consider 2 annotations equal if they belong to the same task, and
246         # their data dics are the same
247         return self.task == other.task and self._data == other._data
248
249     __repr__ = __unicode__
250
251
252 class Task(TaskResource):
253     read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
254
255     class DoesNotExist(Exception):
256         pass
257
258     class CompletedTask(Exception):
259         """
260         Raised when the operation cannot be performed on the completed task.
261         """
262         pass
263
264     class DeletedTask(Exception):
265         """
266         Raised when the operation cannot be performed on the deleted task.
267         """
268         pass
269
270     class NotSaved(Exception):
271         """
272         Raised when the operation cannot be performed on the task, because
273         it has not been saved to TaskWarrior yet.
274         """
275         pass
276
277     @classmethod
278     def from_input(cls, input_file=sys.stdin, modify=None):
279         """
280         Creates a Task object, directly from the stdin, by reading one line.
281         If modify=True, two lines are used, first line interpreted as the
282         original state of the Task object, and second line as its new,
283         modified value. This is consistent with the TaskWarrior's hook
284         system.
285
286         Object created by this method should not be saved, deleted
287         or refreshed, as t could create a infinite loop. For this
288         reason, TaskWarrior instance is set to None.
289
290         Input_file argument can be used to specify the input file,
291         but defaults to sys.stdin.
292         """
293
294         # TaskWarrior instance is set to None
295         task = cls(None)
296
297         # Detect the hook type if not given directly
298         name = os.path.basename(sys.argv[0])
299         modify = name.startswith('on-modify') if modify is None else modify
300
301         # Load the data from the input
302         task._load_data(json.loads(input_file.readline().strip()))
303
304         # If this is a on-modify event, we are provided with additional
305         # line of input, which provides updated data
306         if modify:
307             task._update_data(json.loads(input_file.readline().strip()))
308
309         return task
310
311     def __init__(self, warrior, **kwargs):
312         self.warrior = warrior
313
314         # Check that user is not able to set read-only value in __init__
315         for key in kwargs.keys():
316             if key in self.read_only_fields:
317                 raise RuntimeError('Field \'%s\' is read-only' % key)
318
319         # We serialize the data in kwargs so that users of the library
320         # do not have to pass different data formats via __setitem__ and
321         # __init__ methods, that would be confusing
322
323         # Rather unfortunate syntax due to python2.6 comaptiblity
324         self._data = dict((key, self._normalize(key, value))
325                           for (key, value) in six.iteritems(kwargs))
326         self._original_data = copy.deepcopy(self._data)
327
328     def __unicode__(self):
329         return self['description']
330
331     def __eq__(self, other):
332         if self['uuid'] and other['uuid']:
333             # For saved Tasks, just define equality by equality of uuids
334             return self['uuid'] == other['uuid']
335         else:
336             # If the tasks are not saved, compare the actual instances
337             return id(self) == id(other)
338
339
340     def __hash__(self):
341         if self['uuid']:
342             # For saved Tasks, just define equality by equality of uuids
343             return self['uuid'].__hash__()
344         else:
345             # If the tasks are not saved, return hash of instance id
346             return id(self).__hash__()
347
348     @property
349     def completed(self):
350         return self['status'] == six.text_type('completed')
351
352     @property
353     def deleted(self):
354         return self['status'] == six.text_type('deleted')
355
356     @property
357     def waiting(self):
358         return self['status'] == six.text_type('waiting')
359
360     @property
361     def pending(self):
362         return self['status'] == six.text_type('pending')
363
364     @property
365     def saved(self):
366         return self['uuid'] is not None or self['id'] is not None
367
368     def serialize_depends(self, cur_dependencies):
369         # Check that all the tasks are saved
370         for task in (cur_dependencies or set()):
371             if not task.saved:
372                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
373                                     'it can be set as dependency.' % task)
374
375         return super(Task, self).serialize_depends(cur_dependencies)
376
377     def format_depends(self):
378         # We need to generate added and removed dependencies list,
379         # since Taskwarrior does not accept redefining dependencies.
380
381         # This cannot be part of serialize_depends, since we need
382         # to keep a list of all depedencies in the _data dictionary,
383         # not just currently added/removed ones
384
385         old_dependencies = self._original_data.get('depends', set())
386
387         added = self['depends'] - old_dependencies
388         removed = old_dependencies - self['depends']
389
390         # Removed dependencies need to be prefixed with '-'
391         return 'depends:' + ','.join(
392                 [t['uuid'] for t in added] +
393                 ['-' + t['uuid'] for t in removed]
394             )
395
396     def format_description(self):
397         # Task version older than 2.4.0 ignores first word of the
398         # task description if description: prefix is used
399         if self.warrior.version < VERSION_2_4_0:
400             return self._data['description']
401         else:
402             return "description:'{0}'".format(self._data['description'] or '')
403
404     def delete(self):
405         if not self.saved:
406             raise Task.NotSaved("Task needs to be saved before it can be deleted")
407
408         # Refresh the status, and raise exception if the task is deleted
409         self.refresh(only_fields=['status'])
410
411         if self.deleted:
412             raise Task.DeletedTask("Task was already deleted")
413
414         self.warrior.execute_command([self['uuid'], 'delete'])
415
416         # Refresh the status again, so that we have updated info stored
417         self.refresh(only_fields=['status'])
418
419
420     def done(self):
421         if not self.saved:
422             raise Task.NotSaved("Task needs to be saved before it can be completed")
423
424         # Refresh, and raise exception if task is already completed/deleted
425         self.refresh(only_fields=['status'])
426
427         if self.completed:
428             raise Task.CompletedTask("Cannot complete a completed task")
429         elif self.deleted:
430             raise Task.DeletedTask("Deleted task cannot be completed")
431
432         self.warrior.execute_command([self['uuid'], 'done'])
433
434         # Refresh the status again, so that we have updated info stored
435         self.refresh(only_fields=['status'])
436
437     def save(self):
438         if self.saved and not self.modified:
439             return
440
441         args = [self['uuid'], 'modify'] if self.saved else ['add']
442         args.extend(self._get_modified_fields_as_args())
443         output = self.warrior.execute_command(args)
444
445         # Parse out the new ID, if the task is being added for the first time
446         if not self.saved:
447             id_lines = [l for l in output if l.startswith('Created task ')]
448
449             # Complain loudly if it seems that more tasks were created
450             # Should not happen
451             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
452                 raise TaskWarriorException("Unexpected output when creating "
453                                            "task: %s" % '\n'.join(id_lines))
454
455             # Circumvent the ID storage, since ID is considered read-only
456             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
457
458         # Refreshing is very important here, as not only modification time
459         # is updated, but arbitrary attribute may have changed due hooks
460         # altering the data before saving
461         self.refresh()
462
463     def add_annotation(self, annotation):
464         if not self.saved:
465             raise Task.NotSaved("Task needs to be saved to add annotation")
466
467         args = [self['uuid'], 'annotate', annotation]
468         self.warrior.execute_command(args)
469         self.refresh(only_fields=['annotations'])
470
471     def remove_annotation(self, annotation):
472         if not self.saved:
473             raise Task.NotSaved("Task needs to be saved to remove annotation")
474
475         if isinstance(annotation, TaskAnnotation):
476             annotation = annotation['description']
477         args = [self['uuid'], 'denotate', annotation]
478         self.warrior.execute_command(args)
479         self.refresh(only_fields=['annotations'])
480
481     def _get_modified_fields_as_args(self):
482         args = []
483
484         def add_field(field):
485             # Add the output of format_field method to args list (defaults to
486             # field:value)
487             serialized_value = self._serialize(field, self._data[field])
488
489             # Empty values should not be enclosed in quotation marks, see
490             # TW-1510
491             if serialized_value is '':
492                 escaped_serialized_value = ''
493             else:
494                 escaped_serialized_value = "'{0}'".format(serialized_value)
495
496             format_default = lambda: "{0}:{1}".format(field,
497                                                       escaped_serialized_value)
498
499             format_func = getattr(self, 'format_{0}'.format(field),
500                                   format_default)
501
502             args.append(format_func())
503
504         # If we're modifying saved task, simply pass on all modified fields
505         if self.saved:
506             for field in self._modified_fields:
507                 add_field(field)
508         # For new tasks, pass all fields that make sense
509         else:
510             for field in self._data.keys():
511                 if field in self.read_only_fields:
512                     continue
513                 add_field(field)
514
515         return args
516
517     def refresh(self, only_fields=[]):
518         # Raise error when trying to refresh a task that has not been saved
519         if not self.saved:
520             raise Task.NotSaved("Task needs to be saved to be refreshed")
521
522         # We need to use ID as backup for uuid here for the refreshes
523         # of newly saved tasks. Any other place in the code is fine
524         # with using UUID only.
525         args = [self['uuid'] or self['id'], 'export']
526         new_data = json.loads(self.warrior.execute_command(args)[0])
527         if only_fields:
528             to_update = dict(
529                 [(k, new_data.get(k)) for k in only_fields])
530             self._update_data(to_update, update_original=True)
531         else:
532             self._load_data(new_data)
533
534 class TaskFilter(SerializingObject):
535     """
536     A set of parameters to filter the task list with.
537     """
538
539     def __init__(self, filter_params=[]):
540         self.filter_params = filter_params
541
542     def add_filter(self, filter_str):
543         self.filter_params.append(filter_str)
544
545     def add_filter_param(self, key, value):
546         key = key.replace('__', '.')
547
548         # Replace the value with empty string, since that is the
549         # convention in TW for empty values
550         attribute_key = key.split('.')[0]
551         value = self._serialize(attribute_key, value)
552
553         # If we are filtering by uuid:, do not use uuid keyword
554         # due to TW-1452 bug
555         if key == 'uuid':
556             self.filter_params.insert(0, value)
557         else:
558             # Surround value with aphostrophes unless it's a empty string
559             value = "'%s'" % value if value else ''
560
561             # We enforce equality match by using 'is' (or 'none') modifier
562             # Without using this syntax, filter fails due to TW-1479
563             modifier = '.is' if value else '.none'
564             key = key + modifier if '.' not in key else key
565
566             self.filter_params.append("{0}:{1}".format(key, value))
567
568     def get_filter_params(self):
569         return [f for f in self.filter_params if f]
570
571     def clone(self):
572         c = self.__class__()
573         c.filter_params = list(self.filter_params)
574         return c
575
576
577 class TaskQuerySet(object):
578     """
579     Represents a lazy lookup for a task objects.
580     """
581
582     def __init__(self, warrior=None, filter_obj=None):
583         self.warrior = warrior
584         self._result_cache = None
585         self.filter_obj = filter_obj or TaskFilter()
586
587     def __deepcopy__(self, memo):
588         """
589         Deep copy of a QuerySet doesn't populate the cache
590         """
591         obj = self.__class__()
592         for k, v in self.__dict__.items():
593             if k in ('_iter', '_result_cache'):
594                 obj.__dict__[k] = None
595             else:
596                 obj.__dict__[k] = copy.deepcopy(v, memo)
597         return obj
598
599     def __repr__(self):
600         data = list(self[:REPR_OUTPUT_SIZE + 1])
601         if len(data) > REPR_OUTPUT_SIZE:
602             data[-1] = "...(remaining elements truncated)..."
603         return repr(data)
604
605     def __len__(self):
606         if self._result_cache is None:
607             self._result_cache = list(self)
608         return len(self._result_cache)
609
610     def __iter__(self):
611         if self._result_cache is None:
612             self._result_cache = self._execute()
613         return iter(self._result_cache)
614
615     def __getitem__(self, k):
616         if self._result_cache is None:
617             self._result_cache = list(self)
618         return self._result_cache.__getitem__(k)
619
620     def __bool__(self):
621         if self._result_cache is not None:
622             return bool(self._result_cache)
623         try:
624             next(iter(self))
625         except StopIteration:
626             return False
627         return True
628
629     def __nonzero__(self):
630         return type(self).__bool__(self)
631
632     def _clone(self, klass=None, **kwargs):
633         if klass is None:
634             klass = self.__class__
635         filter_obj = self.filter_obj.clone()
636         c = klass(warrior=self.warrior, filter_obj=filter_obj)
637         c.__dict__.update(kwargs)
638         return c
639
640     def _execute(self):
641         """
642         Fetch the tasks which match the current filters.
643         """
644         return self.warrior.filter_tasks(self.filter_obj)
645
646     def all(self):
647         """
648         Returns a new TaskQuerySet that is a copy of the current one.
649         """
650         return self._clone()
651
652     def pending(self):
653         return self.filter(status=PENDING)
654
655     def completed(self):
656         return self.filter(status=COMPLETED)
657
658     def filter(self, *args, **kwargs):
659         """
660         Returns a new TaskQuerySet with the given filters added.
661         """
662         clone = self._clone()
663         for f in args:
664             clone.filter_obj.add_filter(f)
665         for key, value in kwargs.items():
666             clone.filter_obj.add_filter_param(key, value)
667         return clone
668
669     def get(self, **kwargs):
670         """
671         Performs the query and returns a single object matching the given
672         keyword arguments.
673         """
674         clone = self.filter(**kwargs)
675         num = len(clone)
676         if num == 1:
677             return clone._result_cache[0]
678         if not num:
679             raise Task.DoesNotExist(
680                 'Task matching query does not exist. '
681                 'Lookup parameters were {0}'.format(kwargs))
682         raise ValueError(
683             'get() returned more than one Task -- it returned {0}! '
684             'Lookup parameters were {1}'.format(num, kwargs))
685
686
687 class TaskWarrior(object):
688     def __init__(self, data_location='~/.task', create=True):
689         data_location = os.path.expanduser(data_location)
690         if create and not os.path.exists(data_location):
691             os.makedirs(data_location)
692         self.config = {
693             'data.location': os.path.expanduser(data_location),
694             'confirmation': 'no',
695             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
696             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
697         }
698         self.tasks = TaskQuerySet(self)
699         self.version = self._get_version()
700
701     def _get_command_args(self, args, config_override={}):
702         command_args = ['task', 'rc:/']
703         config = self.config.copy()
704         config.update(config_override)
705         for item in config.items():
706             command_args.append('rc.{0}={1}'.format(*item))
707         command_args.extend(map(str, args))
708         return command_args
709
710     def _get_version(self):
711         p = subprocess.Popen(
712                 ['task', '--version'],
713                 stdout=subprocess.PIPE,
714                 stderr=subprocess.PIPE)
715         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
716         return stdout.strip('\n')
717
718     def execute_command(self, args, config_override={}):
719         command_args = self._get_command_args(
720             args, config_override=config_override)
721         logger.debug(' '.join(command_args))
722         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
723                              stderr=subprocess.PIPE)
724         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
725         if p.returncode:
726             if stderr.strip():
727                 error_msg = stderr.strip().splitlines()[-1]
728             else:
729                 error_msg = stdout.strip()
730             raise TaskWarriorException(error_msg)
731         return stdout.strip().split('\n')
732
733     def filter_tasks(self, filter_obj):
734         args = ['export', '--'] + filter_obj.get_filter_params()
735         tasks = []
736         for line in self.execute_command(args):
737             if line:
738                 data = line.strip(',')
739                 try:
740                     filtered_task = Task(self)
741                     filtered_task._load_data(json.loads(data))
742                     tasks.append(filtered_task)
743                 except ValueError:
744                     raise TaskWarriorException('Invalid JSON: %s' % data)
745         return tasks
746
747     def merge_with(self, path, push=False):
748         path = path.rstrip('/') + '/'
749         self.execute_command(['merge', path], config_override={
750             'merge.autopush': 'yes' if push else 'no',
751         })
752
753     def undo(self):
754         self.execute_command(['undo'])