]> 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:

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