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
11 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
14 COMPLETED = 'completed'
16 VERSION_2_1_0 = six.u('2.1.0')
17 VERSION_2_2_0 = six.u('2.2.0')
18 VERSION_2_3_0 = six.u('2.3.0')
19 VERSION_2_4_0 = six.u('2.4.0')
21 logger = logging.getLogger(__name__)
24 class TaskWarriorException(Exception):
28 class SerializingObject(object):
30 Common ancestor for TaskResource & TaskFilter, since they both
31 need to serialize arguments.
33 Serializing method should hold the following contract:
34 - any empty value (meaning removal of the attribute)
35 is deserialized into a empty string
36 - None denotes a empty value for any attribute
38 Deserializing method should hold the following contract:
39 - None denotes a empty value for any attribute (however,
40 this is here as a safeguard, TaskWarrior currently does
41 not export empty-valued attributes) if the attribute
42 is not iterable (e.g. list or set), in which case
43 a empty iterable should be used.
46 def _deserialize(self, key, value):
47 hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
48 lambda x: x if x != '' else None)
49 return hydrate_func(value)
51 def _serialize(self, key, value):
52 dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
53 lambda x: x if x is not None else '')
54 return dehydrate_func(value)
56 def _normalize(self, key, value):
58 Use normalize_<key> methods to normalize user input. Any user
59 input will be normalized at the moment it is used as filter,
60 or entered as a value of Task attribute.
63 normalize_func = getattr(self, 'normalize_{0}'.format(key),
66 return normalize_func(value)
68 def timestamp_serializer(self, date):
71 return date.strftime(DATE_FORMAT)
73 def timestamp_deserializer(self, date_str):
76 return datetime.datetime.strptime(date_str, DATE_FORMAT)
78 def serialize_entry(self, value):
79 return self.timestamp_serializer(value)
81 def deserialize_entry(self, value):
82 return self.timestamp_deserializer(value)
84 def serialize_modified(self, value):
85 return self.timestamp_serializer(value)
87 def deserialize_modified(self, value):
88 return self.timestamp_deserializer(value)
90 def serialize_due(self, value):
91 return self.timestamp_serializer(value)
93 def deserialize_due(self, value):
94 return self.timestamp_deserializer(value)
96 def serialize_scheduled(self, value):
97 return self.timestamp_serializer(value)
99 def deserialize_scheduled(self, value):
100 return self.timestamp_deserializer(value)
102 def serialize_until(self, value):
103 return self.timestamp_serializer(value)
105 def deserialize_until(self, value):
106 return self.timestamp_deserializer(value)
108 def serialize_wait(self, value):
109 return self.timestamp_serializer(value)
111 def deserialize_wait(self, value):
112 return self.timestamp_deserializer(value)
114 def serialize_annotations(self, value):
115 value = value if value is not None else []
117 # This may seem weird, but it's correct, we want to export
118 # a list of dicts as serialized value
119 serialized_annotations = [json.loads(annotation.export_data())
120 for annotation in value]
121 return serialized_annotations if serialized_annotations else ''
123 def deserialize_annotations(self, data):
124 return [TaskAnnotation(self, d) for d in data] if data else []
126 def serialize_tags(self, tags):
127 return ','.join(tags) if tags else ''
129 def deserialize_tags(self, tags):
130 if isinstance(tags, six.string_types):
131 return tags.split(',') if tags else []
134 def serialize_depends(self, value):
135 # Return the list of uuids
136 value = value if value is not None else set()
137 return ','.join(task['uuid'] for task in value)
139 def deserialize_depends(self, raw_uuids):
140 raw_uuids = raw_uuids or '' # Convert None to empty string
141 uuids = raw_uuids.split(',')
142 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
145 class TaskResource(SerializingObject):
146 read_only_fields = []
148 def _load_data(self, data):
149 self._data = dict((key, self._deserialize(key, value))
150 for key, value in data.items())
151 # We need to use a copy for original data, so that changes
152 # are not propagated.
153 self._original_data = copy.deepcopy(self._data)
155 def _update_data(self, data, update_original=False):
157 Low level update of the internal _data dict. Data which are coming as
158 updates should already be serialized. If update_original is True, the
159 original_data dict is updated as well.
161 self._data.update(dict((key, self._deserialize(key, value))
162 for key, value in data.items()))
165 self._original_data = copy.deepcopy(self._data)
168 def __getitem__(self, key):
169 # This is a workaround to make TaskResource non-iterable
170 # over simple index-based iteration
177 if key not in self._data:
178 self._data[key] = self._deserialize(key, None)
180 return self._data.get(key)
182 def __setitem__(self, key, value):
183 if key in self.read_only_fields:
184 raise RuntimeError('Field \'%s\' is read-only' % key)
185 self._data[key] = value
188 s = six.text_type(self.__unicode__())
190 s = s.encode('utf-8')
196 def export_data(self):
198 Exports current data contained in the Task as JSON
201 # We need to remove spaces for TW-1504, use custom separators
202 data_tuples = ((key, self._serialize(key, value))
203 for key, value in six.iteritems(self._data))
205 # Empty string denotes empty serialized value, we do not want
206 # to pass that to TaskWarrior.
207 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
208 data = dict(data_tuples)
209 return json.dumps(data, separators=(',',':'))
212 def _modified_fields(self):
213 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
214 for key in writable_fields:
215 new_value = self._data.get(key)
216 old_value = self._original_data.get(key)
218 # Make sure not to mark data removal as modified field if the
219 # field originally had some empty value
220 if key in self._data and not new_value and not old_value:
223 if new_value != old_value:
228 return bool(list(self._modified_fields))
231 class TaskAnnotation(TaskResource):
232 read_only_fields = ['entry', 'description']
234 def __init__(self, task, data={}):
236 self._load_data(data)
239 self.task.remove_annotation(self)
241 def __unicode__(self):
242 return self['description']
244 def __eq__(self, other):
245 # consider 2 annotations equal if they belong to the same task, and
246 # their data dics are the same
247 return self.task == other.task and self._data == other._data
249 __repr__ = __unicode__
252 class Task(TaskResource):
253 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
255 class DoesNotExist(Exception):
258 class CompletedTask(Exception):
260 Raised when the operation cannot be performed on the completed task.
264 class DeletedTask(Exception):
266 Raised when the operation cannot be performed on the deleted task.
270 class NotSaved(Exception):
272 Raised when the operation cannot be performed on the task, because
273 it has not been saved to TaskWarrior yet.
278 def from_input(cls, input_file=sys.stdin, modify=None):
280 Creates a Task object, directly from the stdin, by reading one line.
281 If modify=True, two lines are used, first line interpreted as the
282 original state of the Task object, and second line as its new,
283 modified value. This is consistent with the TaskWarrior's hook
286 Object created by this method should not be saved, deleted
287 or refreshed, as t could create a infinite loop. For this
288 reason, TaskWarrior instance is set to None.
290 Input_file argument can be used to specify the input file,
291 but defaults to sys.stdin.
294 # TaskWarrior instance is set to None
297 # Detect the hook type if not given directly
298 name = os.path.basename(sys.argv[0])
299 modify = name.startswith('on-modify') if modify is None else modify
301 # Load the data from the input
302 task._load_data(json.loads(input_file.readline().strip()))
304 # If this is a on-modify event, we are provided with additional
305 # line of input, which provides updated data
307 task._update_data(json.loads(input_file.readline().strip()))
311 def __init__(self, warrior, **kwargs):
312 self.warrior = warrior
314 # Check that user is not able to set read-only value in __init__
315 for key in kwargs.keys():
316 if key in self.read_only_fields:
317 raise RuntimeError('Field \'%s\' is read-only' % key)
319 # We serialize the data in kwargs so that users of the library
320 # do not have to pass different data formats via __setitem__ and
321 # __init__ methods, that would be confusing
323 # Rather unfortunate syntax due to python2.6 comaptiblity
324 self._data = dict((key, self._normalize(key, value))
325 for (key, value) in six.iteritems(kwargs))
326 self._original_data = copy.deepcopy(self._data)
328 def __unicode__(self):
329 return self['description']
331 def __eq__(self, other):
332 if self['uuid'] and other['uuid']:
333 # For saved Tasks, just define equality by equality of uuids
334 return self['uuid'] == other['uuid']
336 # If the tasks are not saved, compare the actual instances
337 return id(self) == id(other)
342 # For saved Tasks, just define equality by equality of uuids
343 return self['uuid'].__hash__()
345 # If the tasks are not saved, return hash of instance id
346 return id(self).__hash__()
350 return self['status'] == six.text_type('completed')
354 return self['status'] == six.text_type('deleted')
358 return self['status'] == six.text_type('waiting')
362 return self['status'] == six.text_type('pending')
366 return self['uuid'] is not None or self['id'] is not None
368 def serialize_depends(self, cur_dependencies):
369 # Check that all the tasks are saved
370 for task in (cur_dependencies or set()):
372 raise Task.NotSaved('Task \'%s\' needs to be saved before '
373 'it can be set as dependency.' % task)
375 return super(Task, self).serialize_depends(cur_dependencies)
377 def format_depends(self):
378 # We need to generate added and removed dependencies list,
379 # since Taskwarrior does not accept redefining dependencies.
381 # This cannot be part of serialize_depends, since we need
382 # to keep a list of all depedencies in the _data dictionary,
383 # not just currently added/removed ones
385 old_dependencies = self._original_data.get('depends', set())
387 added = self['depends'] - old_dependencies
388 removed = old_dependencies - self['depends']
390 # Removed dependencies need to be prefixed with '-'
391 return 'depends:' + ','.join(
392 [t['uuid'] for t in added] +
393 ['-' + t['uuid'] for t in removed]
396 def format_description(self):
397 # Task version older than 2.4.0 ignores first word of the
398 # task description if description: prefix is used
399 if self.warrior.version < VERSION_2_4_0:
400 return self._data['description']
402 return "description:'{0}'".format(self._data['description'] or '')
406 raise Task.NotSaved("Task needs to be saved before it can be deleted")
408 # Refresh the status, and raise exception if the task is deleted
409 self.refresh(only_fields=['status'])
412 raise Task.DeletedTask("Task was already deleted")
414 self.warrior.execute_command([self['uuid'], 'delete'])
416 # Refresh the status again, so that we have updated info stored
417 self.refresh(only_fields=['status'])
422 raise Task.NotSaved("Task needs to be saved before it can be completed")
424 # Refresh, and raise exception if task is already completed/deleted
425 self.refresh(only_fields=['status'])
428 raise Task.CompletedTask("Cannot complete a completed task")
430 raise Task.DeletedTask("Deleted task cannot be completed")
432 self.warrior.execute_command([self['uuid'], 'done'])
434 # Refresh the status again, so that we have updated info stored
435 self.refresh(only_fields=['status'])
438 if self.saved and not self.modified:
441 args = [self['uuid'], 'modify'] if self.saved else ['add']
442 args.extend(self._get_modified_fields_as_args())
443 output = self.warrior.execute_command(args)
445 # Parse out the new ID, if the task is being added for the first time
447 id_lines = [l for l in output if l.startswith('Created task ')]
449 # Complain loudly if it seems that more tasks were created
451 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
452 raise TaskWarriorException("Unexpected output when creating "
453 "task: %s" % '\n'.join(id_lines))
455 # Circumvent the ID storage, since ID is considered read-only
456 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
458 # Refreshing is very important here, as not only modification time
459 # is updated, but arbitrary attribute may have changed due hooks
460 # altering the data before saving
463 def add_annotation(self, annotation):
465 raise Task.NotSaved("Task needs to be saved to add annotation")
467 args = [self['uuid'], 'annotate', annotation]
468 self.warrior.execute_command(args)
469 self.refresh(only_fields=['annotations'])
471 def remove_annotation(self, annotation):
473 raise Task.NotSaved("Task needs to be saved to remove annotation")
475 if isinstance(annotation, TaskAnnotation):
476 annotation = annotation['description']
477 args = [self['uuid'], 'denotate', annotation]
478 self.warrior.execute_command(args)
479 self.refresh(only_fields=['annotations'])
481 def _get_modified_fields_as_args(self):
484 def add_field(field):
485 # Add the output of format_field method to args list (defaults to
487 serialized_value = self._serialize(field, self._data[field])
489 # Empty values should not be enclosed in quotation marks, see
491 if serialized_value is '':
492 escaped_serialized_value = ''
494 escaped_serialized_value = "'{0}'".format(serialized_value)
496 format_default = lambda: "{0}:{1}".format(field,
497 escaped_serialized_value)
499 format_func = getattr(self, 'format_{0}'.format(field),
502 args.append(format_func())
504 # If we're modifying saved task, simply pass on all modified fields
506 for field in self._modified_fields:
508 # For new tasks, pass all fields that make sense
510 for field in self._data.keys():
511 if field in self.read_only_fields:
517 def refresh(self, only_fields=[]):
518 # Raise error when trying to refresh a task that has not been saved
520 raise Task.NotSaved("Task needs to be saved to be refreshed")
522 # We need to use ID as backup for uuid here for the refreshes
523 # of newly saved tasks. Any other place in the code is fine
524 # with using UUID only.
525 args = [self['uuid'] or self['id'], 'export']
526 new_data = json.loads(self.warrior.execute_command(args)[0])
529 [(k, new_data.get(k)) for k in only_fields])
530 self._update_data(to_update, update_original=True)
532 self._load_data(new_data)
534 class TaskFilter(SerializingObject):
536 A set of parameters to filter the task list with.
539 def __init__(self, filter_params=[]):
540 self.filter_params = filter_params
542 def add_filter(self, filter_str):
543 self.filter_params.append(filter_str)
545 def add_filter_param(self, key, value):
546 key = key.replace('__', '.')
548 # Replace the value with empty string, since that is the
549 # convention in TW for empty values
550 attribute_key = key.split('.')[0]
551 value = self._serialize(attribute_key, value)
553 # If we are filtering by uuid:, do not use uuid keyword
556 self.filter_params.insert(0, value)
558 # Surround value with aphostrophes unless it's a empty string
559 value = "'%s'" % value if value else ''
561 # We enforce equality match by using 'is' (or 'none') modifier
562 # Without using this syntax, filter fails due to TW-1479
563 modifier = '.is' if value else '.none'
564 key = key + modifier if '.' not in key else key
566 self.filter_params.append("{0}:{1}".format(key, value))
568 def get_filter_params(self):
569 return [f for f in self.filter_params if f]
573 c.filter_params = list(self.filter_params)
577 class TaskQuerySet(object):
579 Represents a lazy lookup for a task objects.
582 def __init__(self, warrior=None, filter_obj=None):
583 self.warrior = warrior
584 self._result_cache = None
585 self.filter_obj = filter_obj or TaskFilter()
587 def __deepcopy__(self, memo):
589 Deep copy of a QuerySet doesn't populate the cache
591 obj = self.__class__()
592 for k, v in self.__dict__.items():
593 if k in ('_iter', '_result_cache'):
594 obj.__dict__[k] = None
596 obj.__dict__[k] = copy.deepcopy(v, memo)
600 data = list(self[:REPR_OUTPUT_SIZE + 1])
601 if len(data) > REPR_OUTPUT_SIZE:
602 data[-1] = "...(remaining elements truncated)..."
606 if self._result_cache is None:
607 self._result_cache = list(self)
608 return len(self._result_cache)
611 if self._result_cache is None:
612 self._result_cache = self._execute()
613 return iter(self._result_cache)
615 def __getitem__(self, k):
616 if self._result_cache is None:
617 self._result_cache = list(self)
618 return self._result_cache.__getitem__(k)
621 if self._result_cache is not None:
622 return bool(self._result_cache)
625 except StopIteration:
629 def __nonzero__(self):
630 return type(self).__bool__(self)
632 def _clone(self, klass=None, **kwargs):
634 klass = self.__class__
635 filter_obj = self.filter_obj.clone()
636 c = klass(warrior=self.warrior, filter_obj=filter_obj)
637 c.__dict__.update(kwargs)
642 Fetch the tasks which match the current filters.
644 return self.warrior.filter_tasks(self.filter_obj)
648 Returns a new TaskQuerySet that is a copy of the current one.
653 return self.filter(status=PENDING)
656 return self.filter(status=COMPLETED)
658 def filter(self, *args, **kwargs):
660 Returns a new TaskQuerySet with the given filters added.
662 clone = self._clone()
664 clone.filter_obj.add_filter(f)
665 for key, value in kwargs.items():
666 clone.filter_obj.add_filter_param(key, value)
669 def get(self, **kwargs):
671 Performs the query and returns a single object matching the given
674 clone = self.filter(**kwargs)
677 return clone._result_cache[0]
679 raise Task.DoesNotExist(
680 'Task matching query does not exist. '
681 'Lookup parameters were {0}'.format(kwargs))
683 'get() returned more than one Task -- it returned {0}! '
684 'Lookup parameters were {1}'.format(num, kwargs))
687 class TaskWarrior(object):
688 def __init__(self, data_location='~/.task', create=True):
689 data_location = os.path.expanduser(data_location)
690 if create and not os.path.exists(data_location):
691 os.makedirs(data_location)
693 'data.location': os.path.expanduser(data_location),
694 'confirmation': 'no',
695 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
696 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
698 self.tasks = TaskQuerySet(self)
699 self.version = self._get_version()
701 def _get_command_args(self, args, config_override={}):
702 command_args = ['task', 'rc:/']
703 config = self.config.copy()
704 config.update(config_override)
705 for item in config.items():
706 command_args.append('rc.{0}={1}'.format(*item))
707 command_args.extend(map(str, args))
710 def _get_version(self):
711 p = subprocess.Popen(
712 ['task', '--version'],
713 stdout=subprocess.PIPE,
714 stderr=subprocess.PIPE)
715 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
716 return stdout.strip('\n')
718 def execute_command(self, args, config_override={}):
719 command_args = self._get_command_args(
720 args, config_override=config_override)
721 logger.debug(' '.join(command_args))
722 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
723 stderr=subprocess.PIPE)
724 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
727 error_msg = stderr.strip().splitlines()[-1]
729 error_msg = stdout.strip()
730 raise TaskWarriorException(error_msg)
731 return stdout.strip().split('\n')
733 def filter_tasks(self, filter_obj):
734 args = ['export', '--'] + filter_obj.get_filter_params()
736 for line in self.execute_command(args):
738 data = line.strip(',')
740 filtered_task = Task(self)
741 filtered_task._load_data(json.loads(data))
742 tasks.append(filtered_task)
744 raise TaskWarriorException('Invalid JSON: %s' % data)
747 def merge_with(self, path, push=False):
748 path = path.rstrip('/') + '/'
749 self.execute_command(['merge', path], config_override={
750 'merge.autopush': 'yes' if push else 'no',
754 self.execute_command(['undo'])