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