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