]> 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(field, serialized_value)
375             format_func = getattr(self, 'format_{0}'.format(field),
376                                   format_default)
377             args.append(format_func())
378
379         # If we're modifying saved task, simply pass on all modified fields
380         if self.saved:
381             for field in self._modified_fields:
382                 add_field(field)
383         # For new tasks, pass all fields that make sense
384         else:
385             for field in self._data.keys():
386                 if field in self.read_only_fields:
387                     continue
388                 add_field(field)
389
390         return args
391
392     def refresh(self):
393         # Raise error when trying to refresh a task that has not been saved
394         if not self.saved:
395             raise Task.NotSaved("Task needs to be saved to be refreshed")
396
397         # We need to use ID as backup for uuid here for the refreshes
398         # of newly saved tasks. Any other place in the code is fine
399         # with using UUID only.
400         args = [self['uuid'] or self['id'], 'export']
401         new_data = json.loads(self.warrior.execute_command(args)[0])
402         self._load_data(new_data)
403
404
405 class TaskFilter(SerializingObject):
406     """
407     A set of parameters to filter the task list with.
408     """
409
410     def __init__(self, filter_params=[]):
411         self.filter_params = filter_params
412
413     def add_filter(self, filter_str):
414         self.filter_params.append(filter_str)
415
416     def add_filter_param(self, key, value):
417         key = key.replace('__', '.')
418
419         # Replace the value with empty string, since that is the
420         # convention in TW for empty values
421         attribute_key = key.split('.')[0]
422         value = self._serialize(attribute_key, value)
423
424         # If we are filtering by uuid:, do not use uuid keyword
425         # due to TW-1452 bug
426         if key == 'uuid':
427             self.filter_params.insert(0, value)
428         else:
429             # Surround value with aphostrophes unless it's a empty string
430             value = "'%s'" % value if value else ''
431
432             # We enforce equality match by using 'is' (or 'none') modifier
433             # Without using this syntax, filter fails due to TW-1479
434             modifier = '.is' if value else '.none'
435             key = key + modifier if '.' not in key else key
436
437             self.filter_params.append("{0}:{1}".format(key, value))
438
439     def get_filter_params(self):
440         return [f for f in self.filter_params if f]
441
442     def clone(self):
443         c = self.__class__()
444         c.filter_params = list(self.filter_params)
445         return c
446
447
448 class TaskQuerySet(object):
449     """
450     Represents a lazy lookup for a task objects.
451     """
452
453     def __init__(self, warrior=None, filter_obj=None):
454         self.warrior = warrior
455         self._result_cache = None
456         self.filter_obj = filter_obj or TaskFilter()
457
458     def __deepcopy__(self, memo):
459         """
460         Deep copy of a QuerySet doesn't populate the cache
461         """
462         obj = self.__class__()
463         for k, v in self.__dict__.items():
464             if k in ('_iter', '_result_cache'):
465                 obj.__dict__[k] = None
466             else:
467                 obj.__dict__[k] = copy.deepcopy(v, memo)
468         return obj
469
470     def __repr__(self):
471         data = list(self[:REPR_OUTPUT_SIZE + 1])
472         if len(data) > REPR_OUTPUT_SIZE:
473             data[-1] = "...(remaining elements truncated)..."
474         return repr(data)
475
476     def __len__(self):
477         if self._result_cache is None:
478             self._result_cache = list(self)
479         return len(self._result_cache)
480
481     def __iter__(self):
482         if self._result_cache is None:
483             self._result_cache = self._execute()
484         return iter(self._result_cache)
485
486     def __getitem__(self, k):
487         if self._result_cache is None:
488             self._result_cache = list(self)
489         return self._result_cache.__getitem__(k)
490
491     def __bool__(self):
492         if self._result_cache is not None:
493             return bool(self._result_cache)
494         try:
495             next(iter(self))
496         except StopIteration:
497             return False
498         return True
499
500     def __nonzero__(self):
501         return type(self).__bool__(self)
502
503     def _clone(self, klass=None, **kwargs):
504         if klass is None:
505             klass = self.__class__
506         filter_obj = self.filter_obj.clone()
507         c = klass(warrior=self.warrior, filter_obj=filter_obj)
508         c.__dict__.update(kwargs)
509         return c
510
511     def _execute(self):
512         """
513         Fetch the tasks which match the current filters.
514         """
515         return self.warrior.filter_tasks(self.filter_obj)
516
517     def all(self):
518         """
519         Returns a new TaskQuerySet that is a copy of the current one.
520         """
521         return self._clone()
522
523     def pending(self):
524         return self.filter(status=PENDING)
525
526     def completed(self):
527         return self.filter(status=COMPLETED)
528
529     def filter(self, *args, **kwargs):
530         """
531         Returns a new TaskQuerySet with the given filters added.
532         """
533         clone = self._clone()
534         for f in args:
535             clone.filter_obj.add_filter(f)
536         for key, value in kwargs.items():
537             clone.filter_obj.add_filter_param(key, value)
538         return clone
539
540     def get(self, **kwargs):
541         """
542         Performs the query and returns a single object matching the given
543         keyword arguments.
544         """
545         clone = self.filter(**kwargs)
546         num = len(clone)
547         if num == 1:
548             return clone._result_cache[0]
549         if not num:
550             raise Task.DoesNotExist(
551                 'Task matching query does not exist. '
552                 'Lookup parameters were {0}'.format(kwargs))
553         raise ValueError(
554             'get() returned more than one Task -- it returned {0}! '
555             'Lookup parameters were {1}'.format(num, kwargs))
556
557
558 class TaskWarrior(object):
559     def __init__(self, data_location='~/.task', create=True):
560         data_location = os.path.expanduser(data_location)
561         if create and not os.path.exists(data_location):
562             os.makedirs(data_location)
563         self.config = {
564             'data.location': os.path.expanduser(data_location),
565             'confirmation': 'no',
566             'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
567         }
568         self.tasks = TaskQuerySet(self)
569         self.version = self._get_version()
570
571     def _get_command_args(self, args, config_override={}):
572         command_args = ['task', 'rc:/']
573         config = self.config.copy()
574         config.update(config_override)
575         for item in config.items():
576             command_args.append('rc.{0}={1}'.format(*item))
577         command_args.extend(map(str, args))
578         return command_args
579
580     def _get_version(self):
581         p = subprocess.Popen(
582                 ['task', '--version'],
583                 stdout=subprocess.PIPE,
584                 stderr=subprocess.PIPE)
585         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
586         return stdout.strip('\n')
587
588     def execute_command(self, args, config_override={}):
589         command_args = self._get_command_args(
590             args, config_override=config_override)
591         logger.debug(' '.join(command_args))
592         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
593                              stderr=subprocess.PIPE)
594         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
595         if p.returncode:
596             if stderr.strip():
597                 error_msg = stderr.strip().splitlines()[-1]
598             else:
599                 error_msg = stdout.strip()
600             raise TaskWarriorException(error_msg)
601         return stdout.strip().split('\n')
602
603     def filter_tasks(self, filter_obj):
604         args = ['export', '--'] + filter_obj.get_filter_params()
605         tasks = []
606         for line in self.execute_command(args):
607             if line:
608                 data = line.strip(',')
609                 try:
610                     filtered_task = Task(self)
611                     filtered_task._load_data(json.loads(data))
612                     tasks.append(filtered_task)
613                 except ValueError:
614                     raise TaskWarriorException('Invalid JSON: %s' % data)
615         return tasks
616
617     def merge_with(self, path, push=False):
618         path = path.rstrip('/') + '/'
619         self.execute_command(['merge', path], config_override={
620             'merge.autopush': 'yes' if push else 'no',
621         })
622
623     def undo(self):
624         self.execute_command(['undo'])