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, cur_dependencies):
114 # Return the list of uuids
115 return ','.join(task['uuid'] for task in cur_dependencies)
117 def deserialize_depends(self, raw_uuids):
118 raw_uuids = raw_uuids or '' # Convert None to empty string
119 uuids = raw_uuids.split(',')
120 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
123 class TaskResource(SerializingObject):
124 read_only_fields = []
126 def _load_data(self, data):
127 self._data = dict((key, self._deserialize(key, value))
128 for key, value in data.items())
129 # We need to use a copy for original data, so that changes
130 # are not propagated.
131 self._original_data = copy.deepcopy(self._data)
133 def _update_data(self, data, update_original=False):
135 Low level update of the internal _data dict. Data which are coming as
136 updates should already be serialized. If update_original is True, the
137 original_data dict is updated as well.
139 self._data.update(dict((key, self._deserialize(key, value))
140 for key, value in data.items()))
143 self._original_data = copy.deepcopy(self._data)
146 def __getitem__(self, key):
147 # This is a workaround to make TaskResource non-iterable
148 # over simple index-based iteration
155 if key not in self._data:
156 self._data[key] = self._deserialize(key, None)
158 return self._data.get(key)
160 def __setitem__(self, key, value):
161 if key in self.read_only_fields:
162 raise RuntimeError('Field \'%s\' is read-only' % key)
163 self._data[key] = value
166 s = six.text_type(self.__unicode__())
168 s = s.encode('utf-8')
175 class TaskAnnotation(TaskResource):
176 read_only_fields = ['entry', 'description']
178 def __init__(self, task, data={}):
180 self._load_data(data)
183 self.task.remove_annotation(self)
185 def __unicode__(self):
186 return self['description']
188 def __eq__(self, other):
189 # consider 2 annotations equal if they belong to the same task, and
190 # their data dics are the same
191 return self.task == other.task and self._data == other._data
193 __repr__ = __unicode__
196 class Task(TaskResource):
197 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
199 class DoesNotExist(Exception):
202 class CompletedTask(Exception):
204 Raised when the operation cannot be performed on the completed task.
208 class DeletedTask(Exception):
210 Raised when the operation cannot be performed on the deleted task.
214 class NotSaved(Exception):
216 Raised when the operation cannot be performed on the task, because
217 it has not been saved to TaskWarrior yet.
222 def from_input(cls, input_file=sys.stdin, modify=None):
224 Creates a Task object, directly from the stdin, by reading one line.
225 If modify=True, two lines are used, first line interpreted as the
226 original state of the Task object, and second line as its new,
227 modified value. This is consistent with the TaskWarrior's hook
230 Object created by this method should not be saved, deleted
231 or refreshed, as t could create a infinite loop. For this
232 reason, TaskWarrior instance is set to None.
234 Input_file argument can be used to specify the input file,
235 but defaults to sys.stdin.
238 # TaskWarrior instance is set to None
241 # Detect the hook type if not given directly
242 name = os.path.basename(sys.argv[0])
243 modify = name.startswith('on-modify') if modify is None else modify
245 # Load the data from the input
246 task._load_data(json.loads(input_file.readline().strip()))
248 # If this is a on-modify event, we are provided with additional
249 # line of input, which provides updated data
251 task._update_data(json.loads(input_file.readline().strip()))
255 def __init__(self, warrior, **kwargs):
256 self.warrior = warrior
258 # Check that user is not able to set read-only value in __init__
259 for key in kwargs.keys():
260 if key in self.read_only_fields:
261 raise RuntimeError('Field \'%s\' is read-only' % key)
263 # We serialize the data in kwargs so that users of the library
264 # do not have to pass different data formats via __setitem__ and
265 # __init__ methods, that would be confusing
267 # Rather unfortunate syntax due to python2.6 comaptiblity
268 self._load_data(dict((key, self._serialize(key, value))
269 for (key, value) in six.iteritems(kwargs)))
271 def __unicode__(self):
272 return self['description']
274 def __eq__(self, other):
275 if self['uuid'] and other['uuid']:
276 # For saved Tasks, just define equality by equality of uuids
277 return self['uuid'] == other['uuid']
279 # If the tasks are not saved, compare the actual instances
280 return id(self) == id(other)
285 # For saved Tasks, just define equality by equality of uuids
286 return self['uuid'].__hash__()
288 # If the tasks are not saved, return hash of instance id
289 return id(self).__hash__()
292 def _modified_fields(self):
293 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
294 for key in writable_fields:
295 new_value = self._data.get(key)
296 old_value = self._original_data.get(key)
298 # Make sure not to mark data removal as modified field if the
299 # field originally had some empty value
300 if key in self._data and not new_value and not old_value:
303 if new_value != old_value:
308 return bool(list(self._modified_fields))
312 return self['status'] == six.text_type('completed')
316 return self['status'] == six.text_type('deleted')
320 return self['status'] == six.text_type('waiting')
324 return self['status'] == six.text_type('pending')
328 return self['uuid'] is not None or self['id'] is not None
330 def serialize_depends(self, cur_dependencies):
331 # Check that all the tasks are saved
332 for task in cur_dependencies:
334 raise Task.NotSaved('Task \'%s\' needs to be saved before '
335 'it can be set as dependency.' % task)
337 return super(Task, self).serialize_depends(cur_dependencies)
339 def format_depends(self):
340 # We need to generate added and removed dependencies list,
341 # since Taskwarrior does not accept redefining dependencies.
343 # This cannot be part of serialize_depends, since we need
344 # to keep a list of all depedencies in the _data dictionary,
345 # not just currently added/removed ones
347 old_dependencies = self._original_data.get('depends', set())
349 added = self['depends'] - old_dependencies
350 removed = old_dependencies - self['depends']
352 # Removed dependencies need to be prefixed with '-'
353 return 'depends:' + ','.join(
354 [t['uuid'] for t in added] +
355 ['-' + t['uuid'] for t in removed]
358 def format_description(self):
359 # Task version older than 2.4.0 ignores first word of the
360 # task description if description: prefix is used
361 if self.warrior.version < VERSION_2_4_0:
362 return self._data['description']
364 return "description:'{0}'".format(self._data['description'] or '')
368 raise Task.NotSaved("Task needs to be saved before it can be deleted")
370 # Refresh the status, and raise exception if the task is deleted
371 self.refresh(only_fields=['status'])
374 raise Task.DeletedTask("Task was already deleted")
376 self.warrior.execute_command([self['uuid'], 'delete'])
378 # Refresh the status again, so that we have updated info stored
379 self.refresh(only_fields=['status'])
384 raise Task.NotSaved("Task needs to be saved before it can be completed")
386 # Refresh, and raise exception if task is already completed/deleted
387 self.refresh(only_fields=['status'])
390 raise Task.CompletedTask("Cannot complete a completed task")
392 raise Task.DeletedTask("Deleted task cannot be completed")
394 self.warrior.execute_command([self['uuid'], 'done'])
396 # Refresh the status again, so that we have updated info stored
397 self.refresh(only_fields=['status'])
400 if self.saved and not self.modified:
403 args = [self['uuid'], 'modify'] if self.saved else ['add']
404 args.extend(self._get_modified_fields_as_args())
405 output = self.warrior.execute_command(args)
407 # Parse out the new ID, if the task is being added for the first time
409 id_lines = [l for l in output if l.startswith('Created task ')]
411 # Complain loudly if it seems that more tasks were created
413 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
414 raise TaskWarriorException("Unexpected output when creating "
415 "task: %s" % '\n'.join(id_lines))
417 # Circumvent the ID storage, since ID is considered read-only
418 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
420 # Refreshing is very important here, as not only modification time
421 # is updated, but arbitrary attribute may have changed due hooks
422 # altering the data before saving
425 def add_annotation(self, annotation):
427 raise Task.NotSaved("Task needs to be saved to add annotation")
429 args = [self['uuid'], 'annotate', annotation]
430 self.warrior.execute_command(args)
431 self.refresh(only_fields=['annotations'])
433 def remove_annotation(self, annotation):
435 raise Task.NotSaved("Task needs to be saved to remove annotation")
437 if isinstance(annotation, TaskAnnotation):
438 annotation = annotation['description']
439 args = [self['uuid'], 'denotate', annotation]
440 self.warrior.execute_command(args)
441 self.refresh(only_fields=['annotations'])
443 def _get_modified_fields_as_args(self):
446 def add_field(field):
447 # Add the output of format_field method to args list (defaults to
449 serialized_value = self._serialize(field, self._data[field])
451 # Empty values should not be enclosed in quotation marks, see
453 if serialized_value is '':
454 escaped_serialized_value = ''
456 escaped_serialized_value = "'{0}'".format(serialized_value)
458 format_default = lambda: "{0}:{1}".format(field,
459 escaped_serialized_value)
461 format_func = getattr(self, 'format_{0}'.format(field),
464 args.append(format_func())
466 # If we're modifying saved task, simply pass on all modified fields
468 for field in self._modified_fields:
470 # For new tasks, pass all fields that make sense
472 for field in self._data.keys():
473 if field in self.read_only_fields:
479 def refresh(self, only_fields=[]):
480 # Raise error when trying to refresh a task that has not been saved
482 raise Task.NotSaved("Task needs to be saved to be refreshed")
484 # We need to use ID as backup for uuid here for the refreshes
485 # of newly saved tasks. Any other place in the code is fine
486 # with using UUID only.
487 args = [self['uuid'] or self['id'], 'export']
488 new_data = json.loads(self.warrior.execute_command(args)[0])
491 [(k, new_data.get(k)) for k in only_fields])
492 self._update_data(to_update, update_original=True)
494 self._load_data(new_data)
496 def export_data(self):
498 Exports current data contained in the Task as JSON
501 # We need to remove spaces for TW-1504, use custom separators
502 data_tuples = ((key, self._serialize(key, value))
503 for key, value in six.iteritems(self._data))
505 # Empty string denotes empty serialized value, we do not want
506 # to pass that to TaskWarrior.
507 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
508 data = dict(data_tuples)
509 return json.dumps(data, separators=(',',':'))
511 class TaskFilter(SerializingObject):
513 A set of parameters to filter the task list with.
516 def __init__(self, filter_params=[]):
517 self.filter_params = filter_params
519 def add_filter(self, filter_str):
520 self.filter_params.append(filter_str)
522 def add_filter_param(self, key, value):
523 key = key.replace('__', '.')
525 # Replace the value with empty string, since that is the
526 # convention in TW for empty values
527 attribute_key = key.split('.')[0]
528 value = self._serialize(attribute_key, value)
530 # If we are filtering by uuid:, do not use uuid keyword
533 self.filter_params.insert(0, value)
535 # Surround value with aphostrophes unless it's a empty string
536 value = "'%s'" % value if value else ''
538 # We enforce equality match by using 'is' (or 'none') modifier
539 # Without using this syntax, filter fails due to TW-1479
540 modifier = '.is' if value else '.none'
541 key = key + modifier if '.' not in key else key
543 self.filter_params.append("{0}:{1}".format(key, value))
545 def get_filter_params(self):
546 return [f for f in self.filter_params if f]
550 c.filter_params = list(self.filter_params)
554 class TaskQuerySet(object):
556 Represents a lazy lookup for a task objects.
559 def __init__(self, warrior=None, filter_obj=None):
560 self.warrior = warrior
561 self._result_cache = None
562 self.filter_obj = filter_obj or TaskFilter()
564 def __deepcopy__(self, memo):
566 Deep copy of a QuerySet doesn't populate the cache
568 obj = self.__class__()
569 for k, v in self.__dict__.items():
570 if k in ('_iter', '_result_cache'):
571 obj.__dict__[k] = None
573 obj.__dict__[k] = copy.deepcopy(v, memo)
577 data = list(self[:REPR_OUTPUT_SIZE + 1])
578 if len(data) > REPR_OUTPUT_SIZE:
579 data[-1] = "...(remaining elements truncated)..."
583 if self._result_cache is None:
584 self._result_cache = list(self)
585 return len(self._result_cache)
588 if self._result_cache is None:
589 self._result_cache = self._execute()
590 return iter(self._result_cache)
592 def __getitem__(self, k):
593 if self._result_cache is None:
594 self._result_cache = list(self)
595 return self._result_cache.__getitem__(k)
598 if self._result_cache is not None:
599 return bool(self._result_cache)
602 except StopIteration:
606 def __nonzero__(self):
607 return type(self).__bool__(self)
609 def _clone(self, klass=None, **kwargs):
611 klass = self.__class__
612 filter_obj = self.filter_obj.clone()
613 c = klass(warrior=self.warrior, filter_obj=filter_obj)
614 c.__dict__.update(kwargs)
619 Fetch the tasks which match the current filters.
621 return self.warrior.filter_tasks(self.filter_obj)
625 Returns a new TaskQuerySet that is a copy of the current one.
630 return self.filter(status=PENDING)
633 return self.filter(status=COMPLETED)
635 def filter(self, *args, **kwargs):
637 Returns a new TaskQuerySet with the given filters added.
639 clone = self._clone()
641 clone.filter_obj.add_filter(f)
642 for key, value in kwargs.items():
643 clone.filter_obj.add_filter_param(key, value)
646 def get(self, **kwargs):
648 Performs the query and returns a single object matching the given
651 clone = self.filter(**kwargs)
654 return clone._result_cache[0]
656 raise Task.DoesNotExist(
657 'Task matching query does not exist. '
658 'Lookup parameters were {0}'.format(kwargs))
660 'get() returned more than one Task -- it returned {0}! '
661 'Lookup parameters were {1}'.format(num, kwargs))
664 class TaskWarrior(object):
665 def __init__(self, data_location='~/.task', create=True):
666 data_location = os.path.expanduser(data_location)
667 if create and not os.path.exists(data_location):
668 os.makedirs(data_location)
670 'data.location': os.path.expanduser(data_location),
671 'confirmation': 'no',
672 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
674 self.tasks = TaskQuerySet(self)
675 self.version = self._get_version()
677 def _get_command_args(self, args, config_override={}):
678 command_args = ['task', 'rc:/']
679 config = self.config.copy()
680 config.update(config_override)
681 for item in config.items():
682 command_args.append('rc.{0}={1}'.format(*item))
683 command_args.extend(map(str, args))
686 def _get_version(self):
687 p = subprocess.Popen(
688 ['task', '--version'],
689 stdout=subprocess.PIPE,
690 stderr=subprocess.PIPE)
691 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
692 return stdout.strip('\n')
694 def execute_command(self, args, config_override={}):
695 command_args = self._get_command_args(
696 args, config_override=config_override)
697 logger.debug(' '.join(command_args))
698 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
699 stderr=subprocess.PIPE)
700 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
703 error_msg = stderr.strip().splitlines()[-1]
705 error_msg = stdout.strip()
706 raise TaskWarriorException(error_msg)
707 return stdout.strip().split('\n')
709 def filter_tasks(self, filter_obj):
710 args = ['export', '--'] + filter_obj.get_filter_params()
712 for line in self.execute_command(args):
714 data = line.strip(',')
716 filtered_task = Task(self)
717 filtered_task._load_data(json.loads(data))
718 tasks.append(filtered_task)
720 raise TaskWarriorException('Invalid JSON: %s' % data)
723 def merge_with(self, path, push=False):
724 path = path.rstrip('/') + '/'
725 self.execute_command(['merge', path], config_override={
726 'merge.autopush': 'yes' if push else 'no',
730 self.execute_command(['undo'])