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

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