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