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