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

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