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.
1 from __future__ import print_function
12 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
15 COMPLETED = 'completed'
17 VERSION_2_1_0 = six.u('2.1.0')
18 VERSION_2_2_0 = six.u('2.2.0')
19 VERSION_2_3_0 = six.u('2.3.0')
20 VERSION_2_4_0 = six.u('2.4.0')
22 logger = logging.getLogger(__name__)
25 class TaskWarriorException(Exception):
29 class SerializingObject(object):
31 Common ancestor for TaskResource & TaskFilter, since they both
32 need to serialize arguments.
34 Serializing method should hold the following contract:
35 - any empty value (meaning removal of the attribute)
36 is deserialized into a empty string
37 - None denotes a empty value for any attribute
39 Deserializing method should hold the following contract:
40 - None denotes a empty value for any attribute (however,
41 this is here as a safeguard, TaskWarrior currently does
42 not export empty-valued attributes) if the attribute
43 is not iterable (e.g. list or set), in which case
44 a empty iterable should be used.
47 def _deserialize(self, key, value):
48 hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
49 lambda x: x if x != '' else None)
50 return hydrate_func(value)
52 def _serialize(self, key, value):
53 dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
54 lambda x: x if x is not None else '')
55 return dehydrate_func(value)
57 def timestamp_serializer(self, date):
60 return date.strftime(DATE_FORMAT)
62 def timestamp_deserializer(self, date_str):
65 return datetime.datetime.strptime(date_str, DATE_FORMAT)
67 def serialize_entry(self, value):
68 return self.timestamp_serializer(value)
70 def deserialize_entry(self, value):
71 return self.timestamp_deserializer(value)
73 def serialize_modified(self, value):
74 return self.timestamp_serializer(value)
76 def deserialize_modified(self, value):
77 return self.timestamp_deserializer(value)
79 def serialize_due(self, value):
80 return self.timestamp_serializer(value)
82 def deserialize_due(self, value):
83 return self.timestamp_deserializer(value)
85 def serialize_scheduled(self, value):
86 return self.timestamp_serializer(value)
88 def deserialize_scheduled(self, value):
89 return self.timestamp_deserializer(value)
91 def serialize_until(self, value):
92 return self.timestamp_serializer(value)
94 def deserialize_until(self, value):
95 return self.timestamp_deserializer(value)
97 def serialize_wait(self, value):
98 return self.timestamp_serializer(value)
100 def deserialize_wait(self, value):
101 return self.timestamp_deserializer(value)
103 def deserialize_annotations(self, data):
104 return [TaskAnnotation(self, d) for d in data] if data else []
106 def serialize_tags(self, tags):
107 return ','.join(tags) if tags else ''
109 def deserialize_tags(self, tags):
110 if isinstance(tags, six.string_types):
111 return tags.split(',') if tags else []
114 def serialize_depends(self, value):
115 # Return the list of uuids
116 value = value if value is not None else set()
117 return ','.join(task['uuid'] for task in value)
119 def deserialize_depends(self, raw_uuids):
120 raw_uuids = raw_uuids or '' # Convert None to empty string
121 uuids = raw_uuids.split(',')
122 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
125 class TaskResource(SerializingObject):
126 read_only_fields = []
128 def _load_data(self, data):
129 self._data = dict((key, self._deserialize(key, value))
130 for key, value in data.items())
131 # We need to use a copy for original data, so that changes
132 # are not propagated.
133 self._original_data = copy.deepcopy(self._data)
135 def _update_data(self, data, update_original=False):
137 Low level update of the internal _data dict. Data which are coming as
138 updates should already be serialized. If update_original is True, the
139 original_data dict is updated as well.
141 self._data.update(dict((key, self._deserialize(key, value))
142 for key, value in data.items()))
145 self._original_data = copy.deepcopy(self._data)
148 def __getitem__(self, key):
149 # This is a workaround to make TaskResource non-iterable
150 # over simple index-based iteration
157 if key not in self._data:
158 self._data[key] = self._deserialize(key, None)
160 return self._data.get(key)
162 def __setitem__(self, key, value):
163 if key in self.read_only_fields:
164 raise RuntimeError('Field \'%s\' is read-only' % key)
165 self._data[key] = value
168 s = six.text_type(self.__unicode__())
170 s = s.encode('utf-8')
177 class TaskAnnotation(TaskResource):
178 read_only_fields = ['entry', 'description']
180 def __init__(self, task, data={}):
182 self._load_data(data)
185 self.task.remove_annotation(self)
187 def __unicode__(self):
188 return self['description']
190 def __eq__(self, other):
191 # consider 2 annotations equal if they belong to the same task, and
192 # their data dics are the same
193 return self.task == other.task and self._data == other._data
195 __repr__ = __unicode__
198 class Task(TaskResource):
199 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
201 class DoesNotExist(Exception):
204 class CompletedTask(Exception):
206 Raised when the operation cannot be performed on the completed task.
210 class DeletedTask(Exception):
212 Raised when the operation cannot be performed on the deleted task.
216 class NotSaved(Exception):
218 Raised when the operation cannot be performed on the task, because
219 it has not been saved to TaskWarrior yet.
224 def from_input(cls, input_file=sys.stdin, modify=None):
226 Creates a Task object, directly from the stdin, by reading one line.
227 If modify=True, two lines are used, first line interpreted as the
228 original state of the Task object, and second line as its new,
229 modified value. This is consistent with the TaskWarrior's hook
232 Object created by this method should not be saved, deleted
233 or refreshed, as t could create a infinite loop. For this
234 reason, TaskWarrior instance is set to None.
236 Input_file argument can be used to specify the input file,
237 but defaults to sys.stdin.
240 # TaskWarrior instance is set to None
243 # Detect the hook type if not given directly
244 name = os.path.basename(sys.argv[0])
245 modify = name.startswith('on-modify') if modify is None else modify
247 # Load the data from the input
248 task._load_data(json.loads(input_file.readline().strip()))
250 # If this is a on-modify event, we are provided with additional
251 # line of input, which provides updated data
253 task._update_data(json.loads(input_file.readline().strip()))
257 def __init__(self, warrior, **kwargs):
258 self.warrior = warrior
260 # Check that user is not able to set read-only value in __init__
261 for key in kwargs.keys():
262 if key in self.read_only_fields:
263 raise RuntimeError('Field \'%s\' is read-only' % key)
265 # We serialize the data in kwargs so that users of the library
266 # do not have to pass different data formats via __setitem__ and
267 # __init__ methods, that would be confusing
269 # Rather unfortunate syntax due to python2.6 comaptiblity
270 self._load_data(dict((key, self._serialize(key, value))
271 for (key, value) in six.iteritems(kwargs)))
273 def __unicode__(self):
274 return self['description']
276 def __eq__(self, other):
277 if self['uuid'] and other['uuid']:
278 # For saved Tasks, just define equality by equality of uuids
279 return self['uuid'] == other['uuid']
281 # If the tasks are not saved, compare the actual instances
282 return id(self) == id(other)
287 # For saved Tasks, just define equality by equality of uuids
288 return self['uuid'].__hash__()
290 # If the tasks are not saved, return hash of instance id
291 return id(self).__hash__()
294 def _modified_fields(self):
295 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
296 for key in writable_fields:
297 new_value = self._data.get(key)
298 old_value = self._original_data.get(key)
300 # Make sure not to mark data removal as modified field if the
301 # field originally had some empty value
302 if key in self._data and not new_value and not old_value:
305 if new_value != old_value:
310 return bool(list(self._modified_fields))
314 return self['status'] == six.text_type('completed')
318 return self['status'] == six.text_type('deleted')
322 return self['status'] == six.text_type('waiting')
326 return self['status'] == six.text_type('pending')
330 return self['uuid'] is not None or self['id'] is not None
332 def serialize_depends(self, cur_dependencies):
333 # Check that all the tasks are saved
334 for task in (cur_dependencies or set()):
336 raise Task.NotSaved('Task \'%s\' needs to be saved before '
337 'it can be set as dependency.' % task)
339 return super(Task, self).serialize_depends(cur_dependencies)
341 def format_depends(self):
342 # We need to generate added and removed dependencies list,
343 # since Taskwarrior does not accept redefining dependencies.
345 # This cannot be part of serialize_depends, since we need
346 # to keep a list of all depedencies in the _data dictionary,
347 # not just currently added/removed ones
349 old_dependencies = self._original_data.get('depends', set())
351 added = self['depends'] - old_dependencies
352 removed = old_dependencies - self['depends']
354 # Removed dependencies need to be prefixed with '-'
355 return 'depends:' + ','.join(
356 [t['uuid'] for t in added] +
357 ['-' + t['uuid'] for t in removed]
360 def format_description(self):
361 # Task version older than 2.4.0 ignores first word of the
362 # task description if description: prefix is used
363 if self.warrior.version < VERSION_2_4_0:
364 return self._data['description']
366 return "description:'{0}'".format(self._data['description'] or '')
370 raise Task.NotSaved("Task needs to be saved before it can be deleted")
372 # Refresh the status, and raise exception if the task is deleted
373 self.refresh(only_fields=['status'])
376 raise Task.DeletedTask("Task was already deleted")
378 self.warrior.execute_command([self['uuid'], 'delete'])
380 # Refresh the status again, so that we have updated info stored
381 self.refresh(only_fields=['status'])
386 raise Task.NotSaved("Task needs to be saved before it can be completed")
388 # Refresh, and raise exception if task is already completed/deleted
389 self.refresh(only_fields=['status'])
392 raise Task.CompletedTask("Cannot complete a completed task")
394 raise Task.DeletedTask("Deleted task cannot be completed")
396 self.warrior.execute_command([self['uuid'], 'done'])
398 # Refresh the status again, so that we have updated info stored
399 self.refresh(only_fields=['status'])
402 if self.saved and not self.modified:
405 args = [self['uuid'], 'modify'] if self.saved else ['add']
406 args.extend(self._get_modified_fields_as_args())
407 output = self.warrior.execute_command(args)
409 # Parse out the new ID, if the task is being added for the first time
411 id_lines = [l for l in output if l.startswith('Created task ')]
413 # Complain loudly if it seems that more tasks were created
415 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
416 raise TaskWarriorException("Unexpected output when creating "
417 "task: %s" % '\n'.join(id_lines))
419 # Circumvent the ID storage, since ID is considered read-only
420 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
422 # Refreshing is very important here, as not only modification time
423 # is updated, but arbitrary attribute may have changed due hooks
424 # altering the data before saving
427 def add_annotation(self, annotation):
429 raise Task.NotSaved("Task needs to be saved to add annotation")
431 args = [self['uuid'], 'annotate', annotation]
432 self.warrior.execute_command(args)
433 self.refresh(only_fields=['annotations'])
435 def remove_annotation(self, annotation):
437 raise Task.NotSaved("Task needs to be saved to remove annotation")
439 if isinstance(annotation, TaskAnnotation):
440 annotation = annotation['description']
441 args = [self['uuid'], 'denotate', annotation]
442 self.warrior.execute_command(args)
443 self.refresh(only_fields=['annotations'])
445 def _get_modified_fields_as_args(self):
448 def add_field(field):
449 # Add the output of format_field method to args list (defaults to
451 serialized_value = self._serialize(field, self._data[field])
453 # Empty values should not be enclosed in quotation marks, see
455 if serialized_value is '':
456 escaped_serialized_value = ''
458 escaped_serialized_value = "'{0}'".format(serialized_value)
460 format_default = lambda: "{0}:{1}".format(field,
461 escaped_serialized_value)
463 format_func = getattr(self, 'format_{0}'.format(field),
466 args.append(format_func())
468 # If we're modifying saved task, simply pass on all modified fields
470 for field in self._modified_fields:
472 # For new tasks, pass all fields that make sense
474 for field in self._data.keys():
475 if field in self.read_only_fields:
481 def refresh(self, only_fields=[]):
482 # Raise error when trying to refresh a task that has not been saved
484 raise Task.NotSaved("Task needs to be saved to be refreshed")
486 # We need to use ID as backup for uuid here for the refreshes
487 # of newly saved tasks. Any other place in the code is fine
488 # with using UUID only.
489 args = [self['uuid'] or self['id'], 'export']
490 new_data = json.loads(self.warrior.execute_command(args)[0])
493 [(k, new_data.get(k)) for k in only_fields])
494 self._update_data(to_update, update_original=True)
496 self._load_data(new_data)
498 def export_data(self):
500 Exports current data contained in the Task as JSON
503 # We need to remove spaces for TW-1504, use custom separators
504 data_tuples = ((key, self._serialize(key, value))
505 for key, value in six.iteritems(self._data))
507 # Empty string denotes empty serialized value, we do not want
508 # to pass that to TaskWarrior.
509 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
510 data = dict(data_tuples)
511 return json.dumps(data, separators=(',',':'))
513 class TaskFilter(SerializingObject):
515 A set of parameters to filter the task list with.
518 def __init__(self, filter_params=[]):
519 self.filter_params = filter_params
521 def add_filter(self, filter_str):
522 self.filter_params.append(filter_str)
524 def add_filter_param(self, key, value):
525 key = key.replace('__', '.')
527 # Replace the value with empty string, since that is the
528 # convention in TW for empty values
529 attribute_key = key.split('.')[0]
530 value = self._serialize(attribute_key, value)
532 # If we are filtering by uuid:, do not use uuid keyword
535 self.filter_params.insert(0, value)
537 # Surround value with aphostrophes unless it's a empty string
538 value = "'%s'" % value if value else ''
540 # We enforce equality match by using 'is' (or 'none') modifier
541 # Without using this syntax, filter fails due to TW-1479
542 modifier = '.is' if value else '.none'
543 key = key + modifier if '.' not in key else key
545 self.filter_params.append("{0}:{1}".format(key, value))
547 def get_filter_params(self):
548 return [f for f in self.filter_params if f]
552 c.filter_params = list(self.filter_params)
556 class TaskQuerySet(object):
558 Represents a lazy lookup for a task objects.
561 def __init__(self, warrior=None, filter_obj=None):
562 self.warrior = warrior
563 self._result_cache = None
564 self.filter_obj = filter_obj or TaskFilter()
566 def __deepcopy__(self, memo):
568 Deep copy of a QuerySet doesn't populate the cache
570 obj = self.__class__()
571 for k, v in self.__dict__.items():
572 if k in ('_iter', '_result_cache'):
573 obj.__dict__[k] = None
575 obj.__dict__[k] = copy.deepcopy(v, memo)
579 data = list(self[:REPR_OUTPUT_SIZE + 1])
580 if len(data) > REPR_OUTPUT_SIZE:
581 data[-1] = "...(remaining elements truncated)..."
585 if self._result_cache is None:
586 self._result_cache = list(self)
587 return len(self._result_cache)
590 if self._result_cache is None:
591 self._result_cache = self._execute()
592 return iter(self._result_cache)
594 def __getitem__(self, k):
595 if self._result_cache is None:
596 self._result_cache = list(self)
597 return self._result_cache.__getitem__(k)
600 if self._result_cache is not None:
601 return bool(self._result_cache)
604 except StopIteration:
608 def __nonzero__(self):
609 return type(self).__bool__(self)
611 def _clone(self, klass=None, **kwargs):
613 klass = self.__class__
614 filter_obj = self.filter_obj.clone()
615 c = klass(warrior=self.warrior, filter_obj=filter_obj)
616 c.__dict__.update(kwargs)
621 Fetch the tasks which match the current filters.
623 return self.warrior.filter_tasks(self.filter_obj)
627 Returns a new TaskQuerySet that is a copy of the current one.
632 return self.filter(status=PENDING)
635 return self.filter(status=COMPLETED)
637 def filter(self, *args, **kwargs):
639 Returns a new TaskQuerySet with the given filters added.
641 clone = self._clone()
643 clone.filter_obj.add_filter(f)
644 for key, value in kwargs.items():
645 clone.filter_obj.add_filter_param(key, value)
648 def get(self, **kwargs):
650 Performs the query and returns a single object matching the given
653 clone = self.filter(**kwargs)
656 return clone._result_cache[0]
658 raise Task.DoesNotExist(
659 'Task matching query does not exist. '
660 'Lookup parameters were {0}'.format(kwargs))
662 'get() returned more than one Task -- it returned {0}! '
663 'Lookup parameters were {1}'.format(num, kwargs))
666 class TaskWarrior(object):
667 def __init__(self, data_location='~/.task', create=True):
668 data_location = os.path.expanduser(data_location)
669 if create and not os.path.exists(data_location):
670 os.makedirs(data_location)
672 'data.location': os.path.expanduser(data_location),
673 'confirmation': 'no',
674 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
676 self.tasks = TaskQuerySet(self)
677 self.version = self._get_version()
679 def _get_command_args(self, args, config_override={}):
680 command_args = ['task', 'rc:/']
681 config = self.config.copy()
682 config.update(config_override)
683 for item in config.items():
684 command_args.append('rc.{0}={1}'.format(*item))
685 command_args.extend(map(str, args))
688 def _get_version(self):
689 p = subprocess.Popen(
690 ['task', '--version'],
691 stdout=subprocess.PIPE,
692 stderr=subprocess.PIPE)
693 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
694 return stdout.strip('\n')
696 def execute_command(self, args, config_override={}):
697 command_args = self._get_command_args(
698 args, config_override=config_override)
699 logger.debug(' '.join(command_args))
700 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
701 stderr=subprocess.PIPE)
702 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
705 error_msg = stderr.strip().splitlines()[-1]
707 error_msg = stdout.strip()
708 raise TaskWarriorException(error_msg)
709 return stdout.strip().split('\n')
711 def filter_tasks(self, filter_obj):
712 args = ['export', '--'] + filter_obj.get_filter_params()
714 for line in self.execute_command(args):
716 data = line.strip(',')
718 filtered_task = Task(self)
719 filtered_task._load_data(json.loads(data))
720 tasks.append(filtered_task)
722 raise TaskWarriorException('Invalid JSON: %s' % data)
725 def merge_with(self, path, push=False):
726 path = path.rstrip('/') + '/'
727 self.execute_command(['merge', path], config_override={
728 'merge.autopush': 'yes' if push else 'no',
732 self.execute_command(['undo'])