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 timestamp_serializer(self, date):
59 return date.strftime(DATE_FORMAT)
61 def timestamp_deserializer(self, date_str):
64 return datetime.datetime.strptime(date_str, DATE_FORMAT)
66 def serialize_entry(self, value):
67 return self.timestamp_serializer(value)
69 def deserialize_entry(self, value):
70 return self.timestamp_deserializer(value)
72 def serialize_modified(self, value):
73 return self.timestamp_serializer(value)
75 def deserialize_modified(self, value):
76 return self.timestamp_deserializer(value)
78 def serialize_due(self, value):
79 return self.timestamp_serializer(value)
81 def deserialize_due(self, value):
82 return self.timestamp_deserializer(value)
84 def serialize_scheduled(self, value):
85 return self.timestamp_serializer(value)
87 def deserialize_scheduled(self, value):
88 return self.timestamp_deserializer(value)
90 def serialize_until(self, value):
91 return self.timestamp_serializer(value)
93 def deserialize_until(self, value):
94 return self.timestamp_deserializer(value)
96 def serialize_wait(self, value):
97 return self.timestamp_serializer(value)
99 def deserialize_wait(self, value):
100 return self.timestamp_deserializer(value)
102 def deserialize_annotations(self, data):
103 return [TaskAnnotation(self, d) for d in data] if data else []
105 def serialize_tags(self, tags):
106 return ','.join(tags) if tags else ''
108 def deserialize_tags(self, tags):
109 if isinstance(tags, six.string_types):
110 return tags.split(',') if tags else []
113 def serialize_depends(self, value):
114 # Return the list of uuids
115 value = value if value is not None else set()
116 return ','.join(task['uuid'] for task in value)
118 def deserialize_depends(self, raw_uuids):
119 raw_uuids = raw_uuids or '' # Convert None to empty string
120 uuids = raw_uuids.split(',')
121 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
124 class TaskResource(SerializingObject):
125 read_only_fields = []
127 def _load_data(self, data):
128 self._data = dict((key, self._deserialize(key, value))
129 for key, value in data.items())
130 # We need to use a copy for original data, so that changes
131 # are not propagated.
132 self._original_data = copy.deepcopy(self._data)
134 def _update_data(self, data, update_original=False):
136 Low level update of the internal _data dict. Data which are coming as
137 updates should already be serialized. If update_original is True, the
138 original_data dict is updated as well.
140 self._data.update(dict((key, self._deserialize(key, value))
141 for key, value in data.items()))
144 self._original_data = copy.deepcopy(self._data)
147 def __getitem__(self, key):
148 # This is a workaround to make TaskResource non-iterable
149 # over simple index-based iteration
156 if key not in self._data:
157 self._data[key] = self._deserialize(key, None)
159 return self._data.get(key)
161 def __setitem__(self, key, value):
162 if key in self.read_only_fields:
163 raise RuntimeError('Field \'%s\' is read-only' % key)
164 self._data[key] = value
167 s = six.text_type(self.__unicode__())
169 s = s.encode('utf-8')
175 def export_data(self):
177 Exports current data contained in the Task as JSON
180 # We need to remove spaces for TW-1504, use custom separators
181 data_tuples = ((key, self._serialize(key, value))
182 for key, value in six.iteritems(self._data))
184 # Empty string denotes empty serialized value, we do not want
185 # to pass that to TaskWarrior.
186 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
187 data = dict(data_tuples)
188 return json.dumps(data, separators=(',',':'))
191 def _modified_fields(self):
192 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
193 for key in writable_fields:
194 new_value = self._data.get(key)
195 old_value = self._original_data.get(key)
197 # Make sure not to mark data removal as modified field if the
198 # field originally had some empty value
199 if key in self._data and not new_value and not old_value:
202 if new_value != old_value:
207 return bool(list(self._modified_fields))
210 class TaskAnnotation(TaskResource):
211 read_only_fields = ['entry', 'description']
213 def __init__(self, task, data={}):
215 self._load_data(data)
218 self.task.remove_annotation(self)
220 def __unicode__(self):
221 return self['description']
223 def __eq__(self, other):
224 # consider 2 annotations equal if they belong to the same task, and
225 # their data dics are the same
226 return self.task == other.task and self._data == other._data
228 __repr__ = __unicode__
231 class Task(TaskResource):
232 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
234 class DoesNotExist(Exception):
237 class CompletedTask(Exception):
239 Raised when the operation cannot be performed on the completed task.
243 class DeletedTask(Exception):
245 Raised when the operation cannot be performed on the deleted task.
249 class NotSaved(Exception):
251 Raised when the operation cannot be performed on the task, because
252 it has not been saved to TaskWarrior yet.
257 def from_input(cls, input_file=sys.stdin, modify=None):
259 Creates a Task object, directly from the stdin, by reading one line.
260 If modify=True, two lines are used, first line interpreted as the
261 original state of the Task object, and second line as its new,
262 modified value. This is consistent with the TaskWarrior's hook
265 Object created by this method should not be saved, deleted
266 or refreshed, as t could create a infinite loop. For this
267 reason, TaskWarrior instance is set to None.
269 Input_file argument can be used to specify the input file,
270 but defaults to sys.stdin.
273 # TaskWarrior instance is set to None
276 # Detect the hook type if not given directly
277 name = os.path.basename(sys.argv[0])
278 modify = name.startswith('on-modify') if modify is None else modify
280 # Load the data from the input
281 task._load_data(json.loads(input_file.readline().strip()))
283 # If this is a on-modify event, we are provided with additional
284 # line of input, which provides updated data
286 task._update_data(json.loads(input_file.readline().strip()))
290 def __init__(self, warrior, **kwargs):
291 self.warrior = warrior
293 # Check that user is not able to set read-only value in __init__
294 for key in kwargs.keys():
295 if key in self.read_only_fields:
296 raise RuntimeError('Field \'%s\' is read-only' % key)
298 # We serialize the data in kwargs so that users of the library
299 # do not have to pass different data formats via __setitem__ and
300 # __init__ methods, that would be confusing
302 # Rather unfortunate syntax due to python2.6 comaptiblity
303 self._load_data(dict((key, self._serialize(key, value))
304 for (key, value) in six.iteritems(kwargs)))
306 def __unicode__(self):
307 return self['description']
309 def __eq__(self, other):
310 if self['uuid'] and other['uuid']:
311 # For saved Tasks, just define equality by equality of uuids
312 return self['uuid'] == other['uuid']
314 # If the tasks are not saved, compare the actual instances
315 return id(self) == id(other)
320 # For saved Tasks, just define equality by equality of uuids
321 return self['uuid'].__hash__()
323 # If the tasks are not saved, return hash of instance id
324 return id(self).__hash__()
328 return self['status'] == six.text_type('completed')
332 return self['status'] == six.text_type('deleted')
336 return self['status'] == six.text_type('waiting')
340 return self['status'] == six.text_type('pending')
344 return self['uuid'] is not None or self['id'] is not None
346 def serialize_depends(self, cur_dependencies):
347 # Check that all the tasks are saved
348 for task in (cur_dependencies or set()):
350 raise Task.NotSaved('Task \'%s\' needs to be saved before '
351 'it can be set as dependency.' % task)
353 return super(Task, self).serialize_depends(cur_dependencies)
355 def format_depends(self):
356 # We need to generate added and removed dependencies list,
357 # since Taskwarrior does not accept redefining dependencies.
359 # This cannot be part of serialize_depends, since we need
360 # to keep a list of all depedencies in the _data dictionary,
361 # not just currently added/removed ones
363 old_dependencies = self._original_data.get('depends', set())
365 added = self['depends'] - old_dependencies
366 removed = old_dependencies - self['depends']
368 # Removed dependencies need to be prefixed with '-'
369 return 'depends:' + ','.join(
370 [t['uuid'] for t in added] +
371 ['-' + t['uuid'] for t in removed]
374 def format_description(self):
375 # Task version older than 2.4.0 ignores first word of the
376 # task description if description: prefix is used
377 if self.warrior.version < VERSION_2_4_0:
378 return self._data['description']
380 return "description:'{0}'".format(self._data['description'] or '')
384 raise Task.NotSaved("Task needs to be saved before it can be deleted")
386 # Refresh the status, and raise exception if the task is deleted
387 self.refresh(only_fields=['status'])
390 raise Task.DeletedTask("Task was already deleted")
392 self.warrior.execute_command([self['uuid'], 'delete'])
394 # Refresh the status again, so that we have updated info stored
395 self.refresh(only_fields=['status'])
400 raise Task.NotSaved("Task needs to be saved before it can be completed")
402 # Refresh, and raise exception if task is already completed/deleted
403 self.refresh(only_fields=['status'])
406 raise Task.CompletedTask("Cannot complete a completed task")
408 raise Task.DeletedTask("Deleted task cannot be completed")
410 self.warrior.execute_command([self['uuid'], 'done'])
412 # Refresh the status again, so that we have updated info stored
413 self.refresh(only_fields=['status'])
416 if self.saved and not self.modified:
419 args = [self['uuid'], 'modify'] if self.saved else ['add']
420 args.extend(self._get_modified_fields_as_args())
421 output = self.warrior.execute_command(args)
423 # Parse out the new ID, if the task is being added for the first time
425 id_lines = [l for l in output if l.startswith('Created task ')]
427 # Complain loudly if it seems that more tasks were created
429 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
430 raise TaskWarriorException("Unexpected output when creating "
431 "task: %s" % '\n'.join(id_lines))
433 # Circumvent the ID storage, since ID is considered read-only
434 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
436 # Refreshing is very important here, as not only modification time
437 # is updated, but arbitrary attribute may have changed due hooks
438 # altering the data before saving
441 def add_annotation(self, annotation):
443 raise Task.NotSaved("Task needs to be saved to add annotation")
445 args = [self['uuid'], 'annotate', annotation]
446 self.warrior.execute_command(args)
447 self.refresh(only_fields=['annotations'])
449 def remove_annotation(self, annotation):
451 raise Task.NotSaved("Task needs to be saved to remove annotation")
453 if isinstance(annotation, TaskAnnotation):
454 annotation = annotation['description']
455 args = [self['uuid'], 'denotate', annotation]
456 self.warrior.execute_command(args)
457 self.refresh(only_fields=['annotations'])
459 def _get_modified_fields_as_args(self):
462 def add_field(field):
463 # Add the output of format_field method to args list (defaults to
465 serialized_value = self._serialize(field, self._data[field])
467 # Empty values should not be enclosed in quotation marks, see
469 if serialized_value is '':
470 escaped_serialized_value = ''
472 escaped_serialized_value = "'{0}'".format(serialized_value)
474 format_default = lambda: "{0}:{1}".format(field,
475 escaped_serialized_value)
477 format_func = getattr(self, 'format_{0}'.format(field),
480 args.append(format_func())
482 # If we're modifying saved task, simply pass on all modified fields
484 for field in self._modified_fields:
486 # For new tasks, pass all fields that make sense
488 for field in self._data.keys():
489 if field in self.read_only_fields:
495 def refresh(self, only_fields=[]):
496 # Raise error when trying to refresh a task that has not been saved
498 raise Task.NotSaved("Task needs to be saved to be refreshed")
500 # We need to use ID as backup for uuid here for the refreshes
501 # of newly saved tasks. Any other place in the code is fine
502 # with using UUID only.
503 args = [self['uuid'] or self['id'], 'export']
504 new_data = json.loads(self.warrior.execute_command(args)[0])
507 [(k, new_data.get(k)) for k in only_fields])
508 self._update_data(to_update, update_original=True)
510 self._load_data(new_data)
512 class TaskFilter(SerializingObject):
514 A set of parameters to filter the task list with.
517 def __init__(self, filter_params=[]):
518 self.filter_params = filter_params
520 def add_filter(self, filter_str):
521 self.filter_params.append(filter_str)
523 def add_filter_param(self, key, value):
524 key = key.replace('__', '.')
526 # Replace the value with empty string, since that is the
527 # convention in TW for empty values
528 attribute_key = key.split('.')[0]
529 value = self._serialize(attribute_key, value)
531 # If we are filtering by uuid:, do not use uuid keyword
534 self.filter_params.insert(0, value)
536 # Surround value with aphostrophes unless it's a empty string
537 value = "'%s'" % value if value else ''
539 # We enforce equality match by using 'is' (or 'none') modifier
540 # Without using this syntax, filter fails due to TW-1479
541 modifier = '.is' if value else '.none'
542 key = key + modifier if '.' not in key else key
544 self.filter_params.append("{0}:{1}".format(key, value))
546 def get_filter_params(self):
547 return [f for f in self.filter_params if f]
551 c.filter_params = list(self.filter_params)
555 class TaskQuerySet(object):
557 Represents a lazy lookup for a task objects.
560 def __init__(self, warrior=None, filter_obj=None):
561 self.warrior = warrior
562 self._result_cache = None
563 self.filter_obj = filter_obj or TaskFilter()
565 def __deepcopy__(self, memo):
567 Deep copy of a QuerySet doesn't populate the cache
569 obj = self.__class__()
570 for k, v in self.__dict__.items():
571 if k in ('_iter', '_result_cache'):
572 obj.__dict__[k] = None
574 obj.__dict__[k] = copy.deepcopy(v, memo)
578 data = list(self[:REPR_OUTPUT_SIZE + 1])
579 if len(data) > REPR_OUTPUT_SIZE:
580 data[-1] = "...(remaining elements truncated)..."
584 if self._result_cache is None:
585 self._result_cache = list(self)
586 return len(self._result_cache)
589 if self._result_cache is None:
590 self._result_cache = self._execute()
591 return iter(self._result_cache)
593 def __getitem__(self, k):
594 if self._result_cache is None:
595 self._result_cache = list(self)
596 return self._result_cache.__getitem__(k)
599 if self._result_cache is not None:
600 return bool(self._result_cache)
603 except StopIteration:
607 def __nonzero__(self):
608 return type(self).__bool__(self)
610 def _clone(self, klass=None, **kwargs):
612 klass = self.__class__
613 filter_obj = self.filter_obj.clone()
614 c = klass(warrior=self.warrior, filter_obj=filter_obj)
615 c.__dict__.update(kwargs)
620 Fetch the tasks which match the current filters.
622 return self.warrior.filter_tasks(self.filter_obj)
626 Returns a new TaskQuerySet that is a copy of the current one.
631 return self.filter(status=PENDING)
634 return self.filter(status=COMPLETED)
636 def filter(self, *args, **kwargs):
638 Returns a new TaskQuerySet with the given filters added.
640 clone = self._clone()
642 clone.filter_obj.add_filter(f)
643 for key, value in kwargs.items():
644 clone.filter_obj.add_filter_param(key, value)
647 def get(self, **kwargs):
649 Performs the query and returns a single object matching the given
652 clone = self.filter(**kwargs)
655 return clone._result_cache[0]
657 raise Task.DoesNotExist(
658 'Task matching query does not exist. '
659 'Lookup parameters were {0}'.format(kwargs))
661 'get() returned more than one Task -- it returned {0}! '
662 'Lookup parameters were {1}'.format(num, kwargs))
665 class TaskWarrior(object):
666 def __init__(self, data_location='~/.task', create=True):
667 data_location = os.path.expanduser(data_location)
668 if create and not os.path.exists(data_location):
669 os.makedirs(data_location)
671 'data.location': os.path.expanduser(data_location),
672 'confirmation': 'no',
673 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
674 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
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'])