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.
34 def _deserialize(self, key, value):
35 hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
36 lambda x: x if x != '' else None)
37 return hydrate_func(value)
39 def _serialize(self, key, value):
40 dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
41 lambda x: x if x is not None else '')
42 return dehydrate_func(value)
44 def timestamp_serializer(self, date):
47 return date.strftime(DATE_FORMAT)
49 def timestamp_deserializer(self, date_str):
52 return datetime.datetime.strptime(date_str, DATE_FORMAT)
54 def serialize_entry(self, value):
55 return self.timestamp_serializer(value)
57 def deserialize_entry(self, value):
58 return self.timestamp_deserializer(value)
60 def serialize_modified(self, value):
61 return self.timestamp_serializer(value)
63 def deserialize_modified(self, value):
64 return self.timestamp_deserializer(value)
66 def serialize_due(self, value):
67 return self.timestamp_serializer(value)
69 def deserialize_due(self, value):
70 return self.timestamp_deserializer(value)
72 def serialize_scheduled(self, value):
73 return self.timestamp_serializer(value)
75 def deserialize_scheduled(self, value):
76 return self.timestamp_deserializer(value)
78 def serialize_until(self, value):
79 return self.timestamp_serializer(value)
81 def deserialize_until(self, value):
82 return self.timestamp_deserializer(value)
84 def serialize_wait(self, value):
85 return self.timestamp_serializer(value)
87 def deserialize_wait(self, value):
88 return self.timestamp_deserializer(value)
90 def deserialize_annotations(self, data):
91 return [TaskAnnotation(self, d) for d in data] if data else []
93 def serialize_tags(self, tags):
94 return ','.join(tags) if tags else ''
96 def deserialize_tags(self, tags):
97 if isinstance(tags, six.string_types):
98 return tags.split(',') if tags else []
101 def serialize_depends(self, cur_dependencies):
102 # Return the list of uuids
103 return ','.join(task['uuid'] for task in cur_dependencies)
105 def deserialize_depends(self, raw_uuids):
106 raw_uuids = raw_uuids or '' # Convert None to empty string
107 uuids = raw_uuids.split(',')
108 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
111 class TaskResource(SerializingObject):
112 read_only_fields = []
114 def _load_data(self, data):
115 self._data = dict((key, self._deserialize(key, value))
116 for key, value in data.items())
117 # We need to use a copy for original data, so that changes
118 # are not propagated.
119 self._original_data = copy.deepcopy(self._data)
121 def _update_data(self, data, update_original=False):
123 Low level update of the internal _data dict. Data which are coming as
124 updates should already be serialized. If update_original is True, the
125 original_data dict is updated as well.
127 self._data.update(dict((key, self._deserialize(key, value))
128 for key, value in data.items()))
131 self._original_data = copy.deepcopy(self._data)
134 def __getitem__(self, key):
135 # This is a workaround to make TaskResource non-iterable
136 # over simple index-based iteration
143 if key not in self._data:
144 self._data[key] = self._deserialize(key, None)
146 return self._data.get(key)
148 def __setitem__(self, key, value):
149 if key in self.read_only_fields:
150 raise RuntimeError('Field \'%s\' is read-only' % key)
151 self._data[key] = value
154 s = six.text_type(self.__unicode__())
156 s = s.encode('utf-8')
163 class TaskAnnotation(TaskResource):
164 read_only_fields = ['entry', 'description']
166 def __init__(self, task, data={}):
168 self._load_data(data)
171 self.task.remove_annotation(self)
173 def __unicode__(self):
174 return self['description']
176 def __eq__(self, other):
177 # consider 2 annotations equal if they belong to the same task, and
178 # their data dics are the same
179 return self.task == other.task and self._data == other._data
181 __repr__ = __unicode__
184 class Task(TaskResource):
185 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
187 class DoesNotExist(Exception):
190 class CompletedTask(Exception):
192 Raised when the operation cannot be performed on the completed task.
196 class DeletedTask(Exception):
198 Raised when the operation cannot be performed on the deleted task.
202 class NotSaved(Exception):
204 Raised when the operation cannot be performed on the task, because
205 it has not been saved to TaskWarrior yet.
210 def from_input(cls, input_file=sys.stdin, modify=None):
212 Creates a Task object, directly from the stdin, by reading one line.
213 If modify=True, two lines are used, first line interpreted as the
214 original state of the Task object, and second line as its new,
215 modified value. This is consistent with the TaskWarrior's hook
218 Object created by this method should not be saved, deleted
219 or refreshed, as t could create a infinite loop. For this
220 reason, TaskWarrior instance is set to None.
222 Input_file argument can be used to specify the input file,
223 but defaults to sys.stdin.
226 # TaskWarrior instance is set to None
229 # Detect the hook type if not given directly
230 name = os.path.basename(sys.argv[0])
231 modify = name.startswith('on-modify') if modify is None else modify
233 # Load the data from the input
234 task._load_data(json.loads(input_file.readline().strip()))
236 # If this is a on-modify event, we are provided with additional
237 # line of input, which provides updated data
239 task._update_data(json.loads(input_file.readline().strip()))
243 def __init__(self, warrior, **kwargs):
244 self.warrior = warrior
246 # Check that user is not able to set read-only value in __init__
247 for key in kwargs.keys():
248 if key in self.read_only_fields:
249 raise RuntimeError('Field \'%s\' is read-only' % key)
251 # We serialize the data in kwargs so that users of the library
252 # do not have to pass different data formats via __setitem__ and
253 # __init__ methods, that would be confusing
255 # Rather unfortunate syntax due to python2.6 comaptiblity
256 self._load_data(dict((key, self._serialize(key, value))
257 for (key, value) in six.iteritems(kwargs)))
259 def __unicode__(self):
260 return self['description']
262 def __eq__(self, other):
263 if self['uuid'] and other['uuid']:
264 # For saved Tasks, just define equality by equality of uuids
265 return self['uuid'] == other['uuid']
267 # If the tasks are not saved, compare the actual instances
268 return id(self) == id(other)
273 # For saved Tasks, just define equality by equality of uuids
274 return self['uuid'].__hash__()
276 # If the tasks are not saved, return hash of instance id
277 return id(self).__hash__()
280 def _modified_fields(self):
281 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
282 for key in writable_fields:
283 new_value = self._data.get(key)
284 old_value = self._original_data.get(key)
286 # Make sure not to mark data removal as modified field if the
287 # field originally had some empty value
288 if key in self._data and not new_value and not old_value:
291 if new_value != old_value:
296 return bool(list(self._modified_fields))
300 return self['status'] == six.text_type('completed')
304 return self['status'] == six.text_type('deleted')
308 return self['status'] == six.text_type('waiting')
312 return self['status'] == six.text_type('pending')
316 return self['uuid'] is not None or self['id'] is not None
318 def serialize_depends(self, cur_dependencies):
319 # Check that all the tasks are saved
320 for task in cur_dependencies:
322 raise Task.NotSaved('Task \'%s\' needs to be saved before '
323 'it can be set as dependency.' % task)
325 return super(Task, self).serialize_depends(cur_dependencies)
327 def format_depends(self):
328 # We need to generate added and removed dependencies list,
329 # since Taskwarrior does not accept redefining dependencies.
331 # This cannot be part of serialize_depends, since we need
332 # to keep a list of all depedencies in the _data dictionary,
333 # not just currently added/removed ones
335 old_dependencies = self._original_data.get('depends', set())
337 added = self['depends'] - old_dependencies
338 removed = old_dependencies - self['depends']
340 # Removed dependencies need to be prefixed with '-'
341 return 'depends:' + ','.join(
342 [t['uuid'] for t in added] +
343 ['-' + t['uuid'] for t in removed]
346 def format_description(self):
347 # Task version older than 2.4.0 ignores first word of the
348 # task description if description: prefix is used
349 if self.warrior.version < VERSION_2_4_0:
350 return self._data['description']
352 return "description:'{0}'".format(self._data['description'] or '')
356 raise Task.NotSaved("Task needs to be saved before it can be deleted")
358 # Refresh the status, and raise exception if the task is deleted
359 self.refresh(only_fields=['status'])
362 raise Task.DeletedTask("Task was already deleted")
364 self.warrior.execute_command([self['uuid'], 'delete'])
366 # Refresh the status again, so that we have updated info stored
367 self.refresh(only_fields=['status'])
372 raise Task.NotSaved("Task needs to be saved before it can be completed")
374 # Refresh, and raise exception if task is already completed/deleted
375 self.refresh(only_fields=['status'])
378 raise Task.CompletedTask("Cannot complete a completed task")
380 raise Task.DeletedTask("Deleted task cannot be completed")
382 self.warrior.execute_command([self['uuid'], 'done'])
384 # Refresh the status again, so that we have updated info stored
385 self.refresh(only_fields=['status'])
388 if self.saved and not self.modified:
391 args = [self['uuid'], 'modify'] if self.saved else ['add']
392 args.extend(self._get_modified_fields_as_args())
393 output = self.warrior.execute_command(args)
395 # Parse out the new ID, if the task is being added for the first time
397 id_lines = [l for l in output if l.startswith('Created task ')]
399 # Complain loudly if it seems that more tasks were created
401 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
402 raise TaskWarriorException("Unexpected output when creating "
403 "task: %s" % '\n'.join(id_lines))
405 # Circumvent the ID storage, since ID is considered read-only
406 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
408 # Refreshing is very important here, as not only modification time
409 # is updated, but arbitrary attribute may have changed due hooks
410 # altering the data before saving
413 def add_annotation(self, annotation):
415 raise Task.NotSaved("Task needs to be saved to add annotation")
417 args = [self['uuid'], 'annotate', annotation]
418 self.warrior.execute_command(args)
419 self.refresh(only_fields=['annotations'])
421 def remove_annotation(self, annotation):
423 raise Task.NotSaved("Task needs to be saved to remove annotation")
425 if isinstance(annotation, TaskAnnotation):
426 annotation = annotation['description']
427 args = [self['uuid'], 'denotate', annotation]
428 self.warrior.execute_command(args)
429 self.refresh(only_fields=['annotations'])
431 def _get_modified_fields_as_args(self):
434 def add_field(field):
435 # Add the output of format_field method to args list (defaults to
437 serialized_value = self._serialize(field, self._data[field])
439 # Empty values should not be enclosed in quotation marks, see
441 if serialized_value is '':
442 escaped_serialized_value = ''
444 escaped_serialized_value = "'{0}'".format(serialized_value)
446 format_default = lambda: "{0}:{1}".format(field,
447 escaped_serialized_value)
449 format_func = getattr(self, 'format_{0}'.format(field),
452 args.append(format_func())
454 # If we're modifying saved task, simply pass on all modified fields
456 for field in self._modified_fields:
458 # For new tasks, pass all fields that make sense
460 for field in self._data.keys():
461 if field in self.read_only_fields:
467 def refresh(self, only_fields=[]):
468 # Raise error when trying to refresh a task that has not been saved
470 raise Task.NotSaved("Task needs to be saved to be refreshed")
472 # We need to use ID as backup for uuid here for the refreshes
473 # of newly saved tasks. Any other place in the code is fine
474 # with using UUID only.
475 args = [self['uuid'] or self['id'], 'export']
476 new_data = json.loads(self.warrior.execute_command(args)[0])
479 [(k, new_data.get(k)) for k in only_fields])
480 self._update_data(to_update, update_original=True)
482 self._load_data(new_data)
484 def export_data(self):
486 Exports current data contained in the Task as JSON
489 # We need to remove spaces for TW-1504, use custom separators
490 data_tuples = ((key, self._serialize(key, value))
491 for key, value in six.iteritems(self._data))
493 # Empty string denotes empty serialized value, we do not want
494 # to pass that to TaskWarrior.
495 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
496 data = dict(data_tuples)
497 return json.dumps(data, separators=(',',':'))
499 class TaskFilter(SerializingObject):
501 A set of parameters to filter the task list with.
504 def __init__(self, filter_params=[]):
505 self.filter_params = filter_params
507 def add_filter(self, filter_str):
508 self.filter_params.append(filter_str)
510 def add_filter_param(self, key, value):
511 key = key.replace('__', '.')
513 # Replace the value with empty string, since that is the
514 # convention in TW for empty values
515 attribute_key = key.split('.')[0]
516 value = self._serialize(attribute_key, value)
518 # If we are filtering by uuid:, do not use uuid keyword
521 self.filter_params.insert(0, value)
523 # Surround value with aphostrophes unless it's a empty string
524 value = "'%s'" % value if value else ''
526 # We enforce equality match by using 'is' (or 'none') modifier
527 # Without using this syntax, filter fails due to TW-1479
528 modifier = '.is' if value else '.none'
529 key = key + modifier if '.' not in key else key
531 self.filter_params.append("{0}:{1}".format(key, value))
533 def get_filter_params(self):
534 return [f for f in self.filter_params if f]
538 c.filter_params = list(self.filter_params)
542 class TaskQuerySet(object):
544 Represents a lazy lookup for a task objects.
547 def __init__(self, warrior=None, filter_obj=None):
548 self.warrior = warrior
549 self._result_cache = None
550 self.filter_obj = filter_obj or TaskFilter()
552 def __deepcopy__(self, memo):
554 Deep copy of a QuerySet doesn't populate the cache
556 obj = self.__class__()
557 for k, v in self.__dict__.items():
558 if k in ('_iter', '_result_cache'):
559 obj.__dict__[k] = None
561 obj.__dict__[k] = copy.deepcopy(v, memo)
565 data = list(self[:REPR_OUTPUT_SIZE + 1])
566 if len(data) > REPR_OUTPUT_SIZE:
567 data[-1] = "...(remaining elements truncated)..."
571 if self._result_cache is None:
572 self._result_cache = list(self)
573 return len(self._result_cache)
576 if self._result_cache is None:
577 self._result_cache = self._execute()
578 return iter(self._result_cache)
580 def __getitem__(self, k):
581 if self._result_cache is None:
582 self._result_cache = list(self)
583 return self._result_cache.__getitem__(k)
586 if self._result_cache is not None:
587 return bool(self._result_cache)
590 except StopIteration:
594 def __nonzero__(self):
595 return type(self).__bool__(self)
597 def _clone(self, klass=None, **kwargs):
599 klass = self.__class__
600 filter_obj = self.filter_obj.clone()
601 c = klass(warrior=self.warrior, filter_obj=filter_obj)
602 c.__dict__.update(kwargs)
607 Fetch the tasks which match the current filters.
609 return self.warrior.filter_tasks(self.filter_obj)
613 Returns a new TaskQuerySet that is a copy of the current one.
618 return self.filter(status=PENDING)
621 return self.filter(status=COMPLETED)
623 def filter(self, *args, **kwargs):
625 Returns a new TaskQuerySet with the given filters added.
627 clone = self._clone()
629 clone.filter_obj.add_filter(f)
630 for key, value in kwargs.items():
631 clone.filter_obj.add_filter_param(key, value)
634 def get(self, **kwargs):
636 Performs the query and returns a single object matching the given
639 clone = self.filter(**kwargs)
642 return clone._result_cache[0]
644 raise Task.DoesNotExist(
645 'Task matching query does not exist. '
646 'Lookup parameters were {0}'.format(kwargs))
648 'get() returned more than one Task -- it returned {0}! '
649 'Lookup parameters were {1}'.format(num, kwargs))
652 class TaskWarrior(object):
653 def __init__(self, data_location='~/.task', create=True):
654 data_location = os.path.expanduser(data_location)
655 if create and not os.path.exists(data_location):
656 os.makedirs(data_location)
658 'data.location': os.path.expanduser(data_location),
659 'confirmation': 'no',
660 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
662 self.tasks = TaskQuerySet(self)
663 self.version = self._get_version()
665 def _get_command_args(self, args, config_override={}):
666 command_args = ['task', 'rc:/']
667 config = self.config.copy()
668 config.update(config_override)
669 for item in config.items():
670 command_args.append('rc.{0}={1}'.format(*item))
671 command_args.extend(map(str, args))
674 def _get_version(self):
675 p = subprocess.Popen(
676 ['task', '--version'],
677 stdout=subprocess.PIPE,
678 stderr=subprocess.PIPE)
679 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
680 return stdout.strip('\n')
682 def execute_command(self, args, config_override={}):
683 command_args = self._get_command_args(
684 args, config_override=config_override)
685 logger.debug(' '.join(command_args))
686 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
687 stderr=subprocess.PIPE)
688 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
691 error_msg = stderr.strip().splitlines()[-1]
693 error_msg = stdout.strip()
694 raise TaskWarriorException(error_msg)
695 return stdout.strip().split('\n')
697 def filter_tasks(self, filter_obj):
698 args = ['export', '--'] + filter_obj.get_filter_params()
700 for line in self.execute_command(args):
702 data = line.strip(',')
704 filtered_task = Task(self)
705 filtered_task._load_data(json.loads(data))
706 tasks.append(filtered_task)
708 raise TaskWarriorException('Invalid JSON: %s' % data)
711 def merge_with(self, path, push=False):
712 path = path.rstrip('/') + '/'
713 self.execute_command(['merge', path], config_override={
714 'merge.autopush': 'yes' if push else 'no',
718 self.execute_command(['undo'])