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

5acabe0e2952ecc032002c00079586655ec9fbe1
[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         if not date:
169             return None
170         return date.strftime(DATE_FORMAT)
171
172     def deserialize_due(self, date_str):
173         if not date_str:
174             return None
175         return datetime.datetime.strptime(date_str, DATE_FORMAT)
176
177     def serialize_depends(self, cur_dependencies):
178         # Check that all the tasks are saved
179         for task in cur_dependencies:
180             if not task.saved:
181                 raise Task.NotSaved('Task \'%s\' needs to be saved before '
182                                     'it can be set as dependency.' % task)
183
184         # Return the list of uuids
185         return ','.join(task['uuid'] for task in cur_dependencies)
186
187     def deserialize_depends(self, raw_uuids):
188         raw_uuids = raw_uuids or ''  # Convert None to empty string
189         uuids = raw_uuids.split(',')
190         return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
191
192     def format_depends(self):
193         # We need to generate added and removed dependencies list,
194         # since Taskwarrior does not accept redefining dependencies.
195
196         # This cannot be part of serialize_depends, since we need
197         # to keep a list of all depedencies in the _data dictionary,
198         # not just currently added/removed ones
199
200         old_dependencies_raw = self._original_data.get('depends','')
201         old_dependencies = self.deserialize_depends(old_dependencies_raw)
202
203         added = self['depends'] - old_dependencies
204         removed = old_dependencies - self['depends']
205
206         # Removed dependencies need to be prefixed with '-'
207         return ','.join(
208                 [t['uuid'] for t in added] +
209                 ['-' + t['uuid'] for t in removed]
210             )
211
212     def deserialize_annotations(self, data):
213         return [TaskAnnotation(self, d) for d in data] if data else []
214
215     def deserialize_tags(self, tags):
216         if isinstance(tags, basestring):
217             return tags.split(',') if tags else []
218         return tags
219
220     def serialize_tags(self, tags):
221         return ','.join(tags) if tags else ''
222
223     def delete(self):
224         if not self.saved:
225             raise Task.NotSaved("Task needs to be saved before it can be deleted")
226
227         # Refresh the status, and raise exception if the task is deleted
228         self.refresh(only_fields=['status'])
229
230         if self.deleted:
231             raise Task.DeletedTask("Task was already deleted")
232
233         self.warrior.execute_command([self['uuid'], 'delete'], config_override={
234             'confirmation': 'no',
235         })
236
237         # Refresh the status again, so that we have updated info stored
238         self.refresh(only_fields=['status'])
239
240
241     def done(self):
242         if not self.saved:
243             raise Task.NotSaved("Task needs to be saved before it can be completed")
244
245         # Refresh, and raise exception if task is already completed/deleted
246         self.refresh(only_fields=['status'])
247
248         if self.completed:
249             raise Task.CompletedTask("Cannot complete a completed task")
250         elif self.deleted:
251             raise Task.DeletedTask("Deleted task cannot be completed")
252
253         self.warrior.execute_command([self['uuid'], 'done'])
254
255         # Refresh the status again, so that we have updated info stored
256         self.refresh(only_fields=['status'])
257
258     def save(self):
259         args = [self['uuid'], 'modify'] if self.saved else ['add']
260         args.extend(self._get_modified_fields_as_args())
261         output = self.warrior.execute_command(args)
262
263         # Parse out the new ID, if the task is being added for the first time
264         if not self.saved:
265             id_lines = [l for l in output if l.startswith('Created task ')]
266
267             # Complain loudly if it seems that more tasks were created
268             # Should not happen
269             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
270                 raise TaskWarriorException("Unexpected output when creating "
271                                            "task: %s" % '\n'.join(id_lines))
272
273             # Circumvent the ID storage, since ID is considered read-only
274             self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
275
276         self.refresh()
277
278     def add_annotation(self, annotation):
279         if not self.saved:
280             raise Task.NotSaved("Task needs to be saved to add annotation")
281
282         args = [self['uuid'], 'annotate', annotation]
283         self.warrior.execute_command(args)
284         self.refresh(only_fields=['annotations'])
285
286     def remove_annotation(self, annotation):
287         if not self.saved:
288             raise Task.NotSaved("Task needs to be saved to add annotation")
289
290         if isinstance(annotation, TaskAnnotation):
291             annotation = annotation['description']
292         args = [self['uuid'], 'denotate', annotation]
293         self.warrior.execute_command(args)
294         self.refresh(only_fields=['annotations'])
295
296     def _get_modified_fields_as_args(self):
297         args = []
298
299         def add_field(field):
300             # Task version older than 2.4.0 ignores first word of the
301             # task description if description: prefix is used
302             if self.warrior.version < VERSION_2_4_0 and field == 'description':
303                 args.append(self._data[field])
304             elif field == 'depends':
305                 args.append('{0}:{1}'.format(field, self.format_depends()))
306             else:
307                 # Use empty string to substitute for None value
308                 args.append('{0}:{1}'.format(field, self._data[field] or ''))
309
310         # If we're modifying saved task, simply pass on all modified fields
311         if self.saved:
312             for field in self._modified_fields:
313                 add_field(field)
314         # For new tasks, pass all fields that make sense
315         else:
316             for field in self._data.keys():
317                 if field in self.read_only_fields:
318                     continue
319                 add_field(field)
320
321         return args
322
323     def refresh(self, only_fields=[]):
324         # Raise error when trying to refresh a task that has not been saved
325         if not self.saved:
326             raise Task.NotSaved("Task needs to be saved to be refreshed")
327
328         # We need to use ID as backup for uuid here for the refreshes
329         # of newly saved tasks. Any other place in the code is fine
330         # with using UUID only.
331         args = [self['uuid'] or self['id'], 'export']
332         new_data = json.loads(self.warrior.execute_command(args)[0])
333         if only_fields:
334             to_update = dict(
335                 [(k, new_data.get(k)) for k in only_fields])
336             self._data.update(to_update)
337             self._original_data.update(to_update)
338         else:
339             self._data = new_data
340             # We need to create a clone for original_data though
341             # Shallow copy is alright, since data dict uses only
342             # primitive data types
343             self._original_data = new_data.copy()
344
345
346 class TaskFilter(object):
347     """
348     A set of parameters to filter the task list with.
349     """
350
351     def __init__(self, filter_params=[]):
352         self.filter_params = filter_params
353
354     def add_filter(self, filter_str):
355         self.filter_params.append(filter_str)
356
357     def add_filter_param(self, key, value):
358         key = key.replace('__', '.')
359
360         # Replace the value with empty string, since that is the
361         # convention in TW for empty values
362         value = value if value is not None else ''
363
364         # If we are filtering by uuid:, do not use uuid keyword
365         # due to TW-1452 bug
366         if key == 'uuid':
367             self.filter_params.insert(0, value)
368         else:
369             self.filter_params.append('{0}:{1}'.format(key, value))
370
371     def get_filter_params(self):
372         return [f for f in self.filter_params if f]
373
374     def clone(self):
375         c = self.__class__()
376         c.filter_params = list(self.filter_params)
377         return c
378
379
380 class TaskQuerySet(object):
381     """
382     Represents a lazy lookup for a task objects.
383     """
384
385     def __init__(self, warrior=None, filter_obj=None):
386         self.warrior = warrior
387         self._result_cache = None
388         self.filter_obj = filter_obj or TaskFilter()
389
390     def __deepcopy__(self, memo):
391         """
392         Deep copy of a QuerySet doesn't populate the cache
393         """
394         obj = self.__class__()
395         for k, v in self.__dict__.items():
396             if k in ('_iter', '_result_cache'):
397                 obj.__dict__[k] = None
398             else:
399                 obj.__dict__[k] = copy.deepcopy(v, memo)
400         return obj
401
402     def __repr__(self):
403         data = list(self[:REPR_OUTPUT_SIZE + 1])
404         if len(data) > REPR_OUTPUT_SIZE:
405             data[-1] = "...(remaining elements truncated)..."
406         return repr(data)
407
408     def __len__(self):
409         if self._result_cache is None:
410             self._result_cache = list(self)
411         return len(self._result_cache)
412
413     def __iter__(self):
414         if self._result_cache is None:
415             self._result_cache = self._execute()
416         return iter(self._result_cache)
417
418     def __getitem__(self, k):
419         if self._result_cache is None:
420             self._result_cache = list(self)
421         return self._result_cache.__getitem__(k)
422
423     def __bool__(self):
424         if self._result_cache is not None:
425             return bool(self._result_cache)
426         try:
427             next(iter(self))
428         except StopIteration:
429             return False
430         return True
431
432     def __nonzero__(self):
433         return type(self).__bool__(self)
434
435     def _clone(self, klass=None, **kwargs):
436         if klass is None:
437             klass = self.__class__
438         filter_obj = self.filter_obj.clone()
439         c = klass(warrior=self.warrior, filter_obj=filter_obj)
440         c.__dict__.update(kwargs)
441         return c
442
443     def _execute(self):
444         """
445         Fetch the tasks which match the current filters.
446         """
447         return self.warrior.filter_tasks(self.filter_obj)
448
449     def all(self):
450         """
451         Returns a new TaskQuerySet that is a copy of the current one.
452         """
453         return self._clone()
454
455     def pending(self):
456         return self.filter(status=PENDING)
457
458     def completed(self):
459         return self.filter(status=COMPLETED)
460
461     def filter(self, *args, **kwargs):
462         """
463         Returns a new TaskQuerySet with the given filters added.
464         """
465         clone = self._clone()
466         for f in args:
467             clone.filter_obj.add_filter(f)
468         for key, value in kwargs.items():
469             clone.filter_obj.add_filter_param(key, value)
470         return clone
471
472     def get(self, **kwargs):
473         """
474         Performs the query and returns a single object matching the given
475         keyword arguments.
476         """
477         clone = self.filter(**kwargs)
478         num = len(clone)
479         if num == 1:
480             return clone._result_cache[0]
481         if not num:
482             raise Task.DoesNotExist(
483                 'Task matching query does not exist. '
484                 'Lookup parameters were {0}'.format(kwargs))
485         raise ValueError(
486             'get() returned more than one Task -- it returned {0}! '
487             'Lookup parameters were {1}'.format(num, kwargs))
488
489
490 class TaskWarrior(object):
491     def __init__(self, data_location='~/.task', create=True):
492         data_location = os.path.expanduser(data_location)
493         if create and not os.path.exists(data_location):
494             os.makedirs(data_location)
495         self.config = {
496             'data.location': os.path.expanduser(data_location),
497         }
498         self.tasks = TaskQuerySet(self)
499         self.version = self._get_version()
500
501     def _get_command_args(self, args, config_override={}):
502         command_args = ['task', 'rc:/']
503         config = self.config.copy()
504         config.update(config_override)
505         for item in config.items():
506             command_args.append('rc.{0}={1}'.format(*item))
507         command_args.extend(map(str, args))
508         return command_args
509
510     def _get_version(self):
511         p = subprocess.Popen(
512                 ['task', '--version'],
513                 stdout=subprocess.PIPE,
514                 stderr=subprocess.PIPE)
515         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
516         return stdout.strip('\n')
517
518     def execute_command(self, args, config_override={}):
519         command_args = self._get_command_args(
520             args, config_override=config_override)
521         logger.debug(' '.join(command_args))
522         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
523                              stderr=subprocess.PIPE)
524         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
525         if p.returncode:
526             if stderr.strip():
527                 error_msg = stderr.strip().splitlines()[-1]
528             else:
529                 error_msg = stdout.strip()
530             raise TaskWarriorException(error_msg)
531         return stdout.strip().split('\n')
532
533     def filter_tasks(self, filter_obj):
534         args = ['export', '--'] + filter_obj.get_filter_params()
535         tasks = []
536         for line in self.execute_command(args):
537             if line:
538                 data = line.strip(',')
539                 try:
540                     tasks.append(Task(self, json.loads(data)))
541                 except ValueError:
542                     raise TaskWarriorException('Invalid JSON: %s' % data)
543         return tasks
544
545     def merge_with(self, path, push=False):
546         path = path.rstrip('/') + '/'
547         self.execute_command(['merge', path], config_override={
548             'merge.autopush': 'yes' if push else 'no',
549         })
550
551     def undo(self):
552         self.execute_command(['undo'], config_override={
553             'confirmation': 'no',
554         })