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

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