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')
176 class TaskAnnotation(TaskResource):
177 read_only_fields = ['entry', 'description']
179 def __init__(self, task, data={}):
181 self._load_data(data)
184 self.task.remove_annotation(self)
186 def __unicode__(self):
187 return self['description']
189 def __eq__(self, other):
190 # consider 2 annotations equal if they belong to the same task, and
191 # their data dics are the same
192 return self.task == other.task and self._data == other._data
194 __repr__ = __unicode__
197 class Task(TaskResource):
198 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
200 class DoesNotExist(Exception):
203 class CompletedTask(Exception):
205 Raised when the operation cannot be performed on the completed task.
209 class DeletedTask(Exception):
211 Raised when the operation cannot be performed on the deleted task.
215 class NotSaved(Exception):
217 Raised when the operation cannot be performed on the task, because
218 it has not been saved to TaskWarrior yet.
223 def from_input(cls, input_file=sys.stdin, modify=None):
225 Creates a Task object, directly from the stdin, by reading one line.
226 If modify=True, two lines are used, first line interpreted as the
227 original state of the Task object, and second line as its new,
228 modified value. This is consistent with the TaskWarrior's hook
231 Object created by this method should not be saved, deleted
232 or refreshed, as t could create a infinite loop. For this
233 reason, TaskWarrior instance is set to None.
235 Input_file argument can be used to specify the input file,
236 but defaults to sys.stdin.
239 # TaskWarrior instance is set to None
242 # Detect the hook type if not given directly
243 name = os.path.basename(sys.argv[0])
244 modify = name.startswith('on-modify') if modify is None else modify
246 # Load the data from the input
247 task._load_data(json.loads(input_file.readline().strip()))
249 # If this is a on-modify event, we are provided with additional
250 # line of input, which provides updated data
252 task._update_data(json.loads(input_file.readline().strip()))
256 def __init__(self, warrior, **kwargs):
257 self.warrior = warrior
259 # Check that user is not able to set read-only value in __init__
260 for key in kwargs.keys():
261 if key in self.read_only_fields:
262 raise RuntimeError('Field \'%s\' is read-only' % key)
264 # We serialize the data in kwargs so that users of the library
265 # do not have to pass different data formats via __setitem__ and
266 # __init__ methods, that would be confusing
268 # Rather unfortunate syntax due to python2.6 comaptiblity
269 self._load_data(dict((key, self._serialize(key, value))
270 for (key, value) in six.iteritems(kwargs)))
272 def __unicode__(self):
273 return self['description']
275 def __eq__(self, other):
276 if self['uuid'] and other['uuid']:
277 # For saved Tasks, just define equality by equality of uuids
278 return self['uuid'] == other['uuid']
280 # If the tasks are not saved, compare the actual instances
281 return id(self) == id(other)
286 # For saved Tasks, just define equality by equality of uuids
287 return self['uuid'].__hash__()
289 # If the tasks are not saved, return hash of instance id
290 return id(self).__hash__()
293 def _modified_fields(self):
294 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
295 for key in writable_fields:
296 new_value = self._data.get(key)
297 old_value = self._original_data.get(key)
299 # Make sure not to mark data removal as modified field if the
300 # field originally had some empty value
301 if key in self._data and not new_value and not old_value:
304 if new_value != old_value:
309 return bool(list(self._modified_fields))
313 return self['status'] == six.text_type('completed')
317 return self['status'] == six.text_type('deleted')
321 return self['status'] == six.text_type('waiting')
325 return self['status'] == six.text_type('pending')
329 return self['uuid'] is not None or self['id'] is not None
331 def serialize_depends(self, cur_dependencies):
332 # Check that all the tasks are saved
333 for task in (cur_dependencies or set()):
335 raise Task.NotSaved('Task \'%s\' needs to be saved before '
336 'it can be set as dependency.' % task)
338 return super(Task, self).serialize_depends(cur_dependencies)
340 def format_depends(self):
341 # We need to generate added and removed dependencies list,
342 # since Taskwarrior does not accept redefining dependencies.
344 # This cannot be part of serialize_depends, since we need
345 # to keep a list of all depedencies in the _data dictionary,
346 # not just currently added/removed ones
348 old_dependencies = self._original_data.get('depends', set())
350 added = self['depends'] - old_dependencies
351 removed = old_dependencies - self['depends']
353 # Removed dependencies need to be prefixed with '-'
354 return 'depends:' + ','.join(
355 [t['uuid'] for t in added] +
356 ['-' + t['uuid'] for t in removed]
359 def format_description(self):
360 # Task version older than 2.4.0 ignores first word of the
361 # task description if description: prefix is used
362 if self.warrior.version < VERSION_2_4_0:
363 return self._data['description']
365 return "description:'{0}'".format(self._data['description'] or '')
369 raise Task.NotSaved("Task needs to be saved before it can be deleted")
371 # Refresh the status, and raise exception if the task is deleted
372 self.refresh(only_fields=['status'])
375 raise Task.DeletedTask("Task was already deleted")
377 self.warrior.execute_command([self['uuid'], 'delete'])
379 # Refresh the status again, so that we have updated info stored
380 self.refresh(only_fields=['status'])
385 raise Task.NotSaved("Task needs to be saved before it can be completed")
387 # Refresh, and raise exception if task is already completed/deleted
388 self.refresh(only_fields=['status'])
391 raise Task.CompletedTask("Cannot complete a completed task")
393 raise Task.DeletedTask("Deleted task cannot be completed")
395 self.warrior.execute_command([self['uuid'], 'done'])
397 # Refresh the status again, so that we have updated info stored
398 self.refresh(only_fields=['status'])
401 if self.saved and not self.modified:
404 args = [self['uuid'], 'modify'] if self.saved else ['add']
405 args.extend(self._get_modified_fields_as_args())
406 output = self.warrior.execute_command(args)
408 # Parse out the new ID, if the task is being added for the first time
410 id_lines = [l for l in output if l.startswith('Created task ')]
412 # Complain loudly if it seems that more tasks were created
414 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
415 raise TaskWarriorException("Unexpected output when creating "
416 "task: %s" % '\n'.join(id_lines))
418 # Circumvent the ID storage, since ID is considered read-only
419 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
421 # Refreshing is very important here, as not only modification time
422 # is updated, but arbitrary attribute may have changed due hooks
423 # altering the data before saving
426 def add_annotation(self, annotation):
428 raise Task.NotSaved("Task needs to be saved to add annotation")
430 args = [self['uuid'], 'annotate', annotation]
431 self.warrior.execute_command(args)
432 self.refresh(only_fields=['annotations'])
434 def remove_annotation(self, annotation):
436 raise Task.NotSaved("Task needs to be saved to remove annotation")
438 if isinstance(annotation, TaskAnnotation):
439 annotation = annotation['description']
440 args = [self['uuid'], 'denotate', annotation]
441 self.warrior.execute_command(args)
442 self.refresh(only_fields=['annotations'])
444 def _get_modified_fields_as_args(self):
447 def add_field(field):
448 # Add the output of format_field method to args list (defaults to
450 serialized_value = self._serialize(field, self._data[field])
452 # Empty values should not be enclosed in quotation marks, see
454 if serialized_value is '':
455 escaped_serialized_value = ''
457 escaped_serialized_value = "'{0}'".format(serialized_value)
459 format_default = lambda: "{0}:{1}".format(field,
460 escaped_serialized_value)
462 format_func = getattr(self, 'format_{0}'.format(field),
465 args.append(format_func())
467 # If we're modifying saved task, simply pass on all modified fields
469 for field in self._modified_fields:
471 # For new tasks, pass all fields that make sense
473 for field in self._data.keys():
474 if field in self.read_only_fields:
480 def refresh(self, only_fields=[]):
481 # Raise error when trying to refresh a task that has not been saved
483 raise Task.NotSaved("Task needs to be saved to be refreshed")
485 # We need to use ID as backup for uuid here for the refreshes
486 # of newly saved tasks. Any other place in the code is fine
487 # with using UUID only.
488 args = [self['uuid'] or self['id'], 'export']
489 new_data = json.loads(self.warrior.execute_command(args)[0])
492 [(k, new_data.get(k)) for k in only_fields])
493 self._update_data(to_update, update_original=True)
495 self._load_data(new_data)
497 def export_data(self):
499 Exports current data contained in the Task as JSON
502 # We need to remove spaces for TW-1504, use custom separators
503 data_tuples = ((key, self._serialize(key, value))
504 for key, value in six.iteritems(self._data))
506 # Empty string denotes empty serialized value, we do not want
507 # to pass that to TaskWarrior.
508 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
509 data = dict(data_tuples)
510 return json.dumps(data, separators=(',',':'))
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
675 self.tasks = TaskQuerySet(self)
676 self.version = self._get_version()
678 def _get_command_args(self, args, config_override={}):
679 command_args = ['task', 'rc:/']
680 config = self.config.copy()
681 config.update(config_override)
682 for item in config.items():
683 command_args.append('rc.{0}={1}'.format(*item))
684 command_args.extend(map(str, args))
687 def _get_version(self):
688 p = subprocess.Popen(
689 ['task', '--version'],
690 stdout=subprocess.PIPE,
691 stderr=subprocess.PIPE)
692 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
693 return stdout.strip('\n')
695 def execute_command(self, args, config_override={}):
696 command_args = self._get_command_args(
697 args, config_override=config_override)
698 logger.debug(' '.join(command_args))
699 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
700 stderr=subprocess.PIPE)
701 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
704 error_msg = stderr.strip().splitlines()[-1]
706 error_msg = stdout.strip()
707 raise TaskWarriorException(error_msg)
708 return stdout.strip().split('\n')
710 def filter_tasks(self, filter_obj):
711 args = ['export', '--'] + filter_obj.get_filter_params()
713 for line in self.execute_command(args):
715 data = line.strip(',')
717 filtered_task = Task(self)
718 filtered_task._load_data(json.loads(data))
719 tasks.append(filtered_task)
721 raise TaskWarriorException('Invalid JSON: %s' % data)
724 def merge_with(self, path, push=False):
725 path = path.rstrip('/') + '/'
726 self.execute_command(['merge', path], config_override={
727 'merge.autopush': 'yes' if push else 'no',
731 self.execute_command(['undo'])