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