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

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