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, modify=False):
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.
223 # TaskWarrior instance is set to None
226 # Load the data from the input
227 task._load_data(json.loads(sys.stdin.readline().strip()))
229 # If this is a on-modify event, we are provided with additional
230 # line of input, which provides updated data
232 task._update_data(json.loads(sys.stdin.readline().strip()))
236 def __init__(self, warrior, **kwargs):
237 self.warrior = warrior
239 # Check that user is not able to set read-only value in __init__
240 for key in kwargs.keys():
241 if key in self.read_only_fields:
242 raise RuntimeError('Field \'%s\' is read-only' % key)
244 # We serialize the data in kwargs so that users of the library
245 # do not have to pass different data formats via __setitem__ and
246 # __init__ methods, that would be confusing
248 # Rather unfortunate syntax due to python2.6 comaptiblity
249 self._load_data(dict((key, self._serialize(key, value))
250 for (key, value) in six.iteritems(kwargs)))
252 def __unicode__(self):
253 return self['description']
255 def __eq__(self, other):
256 if self['uuid'] and other['uuid']:
257 # For saved Tasks, just define equality by equality of uuids
258 return self['uuid'] == other['uuid']
260 # If the tasks are not saved, compare the actual instances
261 return id(self) == id(other)
266 # For saved Tasks, just define equality by equality of uuids
267 return self['uuid'].__hash__()
269 # If the tasks are not saved, return hash of instance id
270 return id(self).__hash__()
273 def _modified_fields(self):
274 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
275 for key in writable_fields:
276 if self._data.get(key) != self._original_data.get(key):
280 def _is_modified(self):
281 return bool(list(self._modified_fields))
285 return self['status'] == six.text_type('completed')
289 return self['status'] == six.text_type('deleted')
293 return self['status'] == six.text_type('waiting')
297 return self['status'] == six.text_type('pending')
301 return self['uuid'] is not None or self['id'] is not None
303 def serialize_depends(self, cur_dependencies):
304 # Check that all the tasks are saved
305 for task in cur_dependencies:
307 raise Task.NotSaved('Task \'%s\' needs to be saved before '
308 'it can be set as dependency.' % task)
310 return super(Task, self).serialize_depends(cur_dependencies)
312 def format_depends(self):
313 # We need to generate added and removed dependencies list,
314 # since Taskwarrior does not accept redefining dependencies.
316 # This cannot be part of serialize_depends, since we need
317 # to keep a list of all depedencies in the _data dictionary,
318 # not just currently added/removed ones
320 old_dependencies = self._original_data.get('depends', set())
322 added = self['depends'] - old_dependencies
323 removed = old_dependencies - self['depends']
325 # Removed dependencies need to be prefixed with '-'
326 return 'depends:' + ','.join(
327 [t['uuid'] for t in added] +
328 ['-' + t['uuid'] for t in removed]
331 def format_description(self):
332 # Task version older than 2.4.0 ignores first word of the
333 # task description if description: prefix is used
334 if self.warrior.version < VERSION_2_4_0:
335 return self._data['description']
337 return "description:'{0}'".format(self._data['description'] or '')
341 raise Task.NotSaved("Task needs to be saved before it can be deleted")
343 # Refresh the status, and raise exception if the task is deleted
344 self.refresh(only_fields=['status'])
347 raise Task.DeletedTask("Task was already deleted")
349 self.warrior.execute_command([self['uuid'], 'delete'])
351 # Refresh the status again, so that we have updated info stored
352 self.refresh(only_fields=['status'])
357 raise Task.NotSaved("Task needs to be saved before it can be completed")
359 # Refresh, and raise exception if task is already completed/deleted
360 self.refresh(only_fields=['status'])
363 raise Task.CompletedTask("Cannot complete a completed task")
365 raise Task.DeletedTask("Deleted task cannot be completed")
367 self.warrior.execute_command([self['uuid'], 'done'])
369 # Refresh the status again, so that we have updated info stored
370 self.refresh(only_fields=['status'])
373 if self.saved and not self._is_modified:
376 args = [self['uuid'], 'modify'] if self.saved else ['add']
377 args.extend(self._get_modified_fields_as_args())
378 output = self.warrior.execute_command(args)
380 # Parse out the new ID, if the task is being added for the first time
382 id_lines = [l for l in output if l.startswith('Created task ')]
384 # Complain loudly if it seems that more tasks were created
386 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
387 raise TaskWarriorException("Unexpected output when creating "
388 "task: %s" % '\n'.join(id_lines))
390 # Circumvent the ID storage, since ID is considered read-only
391 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
393 # Refreshing is very important here, as not only modification time
394 # is updated, but arbitrary attribute may have changed due hooks
395 # altering the data before saving
398 def add_annotation(self, annotation):
400 raise Task.NotSaved("Task needs to be saved to add annotation")
402 args = [self['uuid'], 'annotate', annotation]
403 self.warrior.execute_command(args)
404 self.refresh(only_fields=['annotations'])
406 def remove_annotation(self, annotation):
408 raise Task.NotSaved("Task needs to be saved to remove annotation")
410 if isinstance(annotation, TaskAnnotation):
411 annotation = annotation['description']
412 args = [self['uuid'], 'denotate', annotation]
413 self.warrior.execute_command(args)
414 self.refresh(only_fields=['annotations'])
416 def _get_modified_fields_as_args(self):
419 def add_field(field):
420 # Add the output of format_field method to args list (defaults to
422 serialized_value = self._serialize(field, self._data[field]) or ''
423 format_default = lambda: "{0}:{1}".format(
425 "'{0}'".format(serialized_value) if serialized_value else ''
427 format_func = getattr(self, 'format_{0}'.format(field),
429 args.append(format_func())
431 # If we're modifying saved task, simply pass on all modified fields
433 for field in self._modified_fields:
435 # For new tasks, pass all fields that make sense
437 for field in self._data.keys():
438 if field in self.read_only_fields:
444 def refresh(self, only_fields=[]):
445 # Raise error when trying to refresh a task that has not been saved
447 raise Task.NotSaved("Task needs to be saved to be refreshed")
449 # We need to use ID as backup for uuid here for the refreshes
450 # of newly saved tasks. Any other place in the code is fine
451 # with using UUID only.
452 args = [self['uuid'] or self['id'], 'export']
453 new_data = json.loads(self.warrior.execute_command(args)[0])
456 [(k, new_data.get(k)) for k in only_fields])
457 self._update_data(to_update, update_original=True)
459 self._load_data(new_data)
461 def export_data(self):
463 Exports current data contained in the Task as JSON
466 # We need to remove spaces for TW-1504, use custom separators
467 return json.dumps(self._data, separators=(',',':'))
469 class TaskFilter(SerializingObject):
471 A set of parameters to filter the task list with.
474 def __init__(self, filter_params=[]):
475 self.filter_params = filter_params
477 def add_filter(self, filter_str):
478 self.filter_params.append(filter_str)
480 def add_filter_param(self, key, value):
481 key = key.replace('__', '.')
483 # Replace the value with empty string, since that is the
484 # convention in TW for empty values
485 attribute_key = key.split('.')[0]
486 value = self._serialize(attribute_key, value)
488 # If we are filtering by uuid:, do not use uuid keyword
491 self.filter_params.insert(0, value)
493 # Surround value with aphostrophes unless it's a empty string
494 value = "'%s'" % value if value else ''
496 # We enforce equality match by using 'is' (or 'none') modifier
497 # Without using this syntax, filter fails due to TW-1479
498 modifier = '.is' if value else '.none'
499 key = key + modifier if '.' not in key else key
501 self.filter_params.append("{0}:{1}".format(key, value))
503 def get_filter_params(self):
504 return [f for f in self.filter_params if f]
508 c.filter_params = list(self.filter_params)
512 class TaskQuerySet(object):
514 Represents a lazy lookup for a task objects.
517 def __init__(self, warrior=None, filter_obj=None):
518 self.warrior = warrior
519 self._result_cache = None
520 self.filter_obj = filter_obj or TaskFilter()
522 def __deepcopy__(self, memo):
524 Deep copy of a QuerySet doesn't populate the cache
526 obj = self.__class__()
527 for k, v in self.__dict__.items():
528 if k in ('_iter', '_result_cache'):
529 obj.__dict__[k] = None
531 obj.__dict__[k] = copy.deepcopy(v, memo)
535 data = list(self[:REPR_OUTPUT_SIZE + 1])
536 if len(data) > REPR_OUTPUT_SIZE:
537 data[-1] = "...(remaining elements truncated)..."
541 if self._result_cache is None:
542 self._result_cache = list(self)
543 return len(self._result_cache)
546 if self._result_cache is None:
547 self._result_cache = self._execute()
548 return iter(self._result_cache)
550 def __getitem__(self, k):
551 if self._result_cache is None:
552 self._result_cache = list(self)
553 return self._result_cache.__getitem__(k)
556 if self._result_cache is not None:
557 return bool(self._result_cache)
560 except StopIteration:
564 def __nonzero__(self):
565 return type(self).__bool__(self)
567 def _clone(self, klass=None, **kwargs):
569 klass = self.__class__
570 filter_obj = self.filter_obj.clone()
571 c = klass(warrior=self.warrior, filter_obj=filter_obj)
572 c.__dict__.update(kwargs)
577 Fetch the tasks which match the current filters.
579 return self.warrior.filter_tasks(self.filter_obj)
583 Returns a new TaskQuerySet that is a copy of the current one.
588 return self.filter(status=PENDING)
591 return self.filter(status=COMPLETED)
593 def filter(self, *args, **kwargs):
595 Returns a new TaskQuerySet with the given filters added.
597 clone = self._clone()
599 clone.filter_obj.add_filter(f)
600 for key, value in kwargs.items():
601 clone.filter_obj.add_filter_param(key, value)
604 def get(self, **kwargs):
606 Performs the query and returns a single object matching the given
609 clone = self.filter(**kwargs)
612 return clone._result_cache[0]
614 raise Task.DoesNotExist(
615 'Task matching query does not exist. '
616 'Lookup parameters were {0}'.format(kwargs))
618 'get() returned more than one Task -- it returned {0}! '
619 'Lookup parameters were {1}'.format(num, kwargs))
622 class TaskWarrior(object):
623 def __init__(self, data_location='~/.task', create=True):
624 data_location = os.path.expanduser(data_location)
625 if create and not os.path.exists(data_location):
626 os.makedirs(data_location)
628 'data.location': os.path.expanduser(data_location),
629 'confirmation': 'no',
630 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
632 self.tasks = TaskQuerySet(self)
633 self.version = self._get_version()
635 def _get_command_args(self, args, config_override={}):
636 command_args = ['task', 'rc:/']
637 config = self.config.copy()
638 config.update(config_override)
639 for item in config.items():
640 command_args.append('rc.{0}={1}'.format(*item))
641 command_args.extend(map(str, args))
644 def _get_version(self):
645 p = subprocess.Popen(
646 ['task', '--version'],
647 stdout=subprocess.PIPE,
648 stderr=subprocess.PIPE)
649 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
650 return stdout.strip('\n')
652 def execute_command(self, args, config_override={}):
653 command_args = self._get_command_args(
654 args, config_override=config_override)
655 logger.debug(' '.join(command_args))
656 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
657 stderr=subprocess.PIPE)
658 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
661 error_msg = stderr.strip().splitlines()[-1]
663 error_msg = stdout.strip()
664 raise TaskWarriorException(error_msg)
665 return stdout.strip().split('\n')
667 def filter_tasks(self, filter_obj):
668 args = ['export', '--'] + filter_obj.get_filter_params()
670 for line in self.execute_command(args):
672 data = line.strip(',')
674 filtered_task = Task(self)
675 filtered_task._load_data(json.loads(data))
676 tasks.append(filtered_task)
678 raise TaskWarriorException('Invalid JSON: %s' % data)
681 def merge_with(self, path, push=False):
682 path = path.rstrip('/') + '/'
683 self.execute_command(['merge', path], config_override={
684 'merge.autopush': 'yes' if push else 'no',
688 self.execute_command(['undo'])