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=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.
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 # Load the data from the input
230 task._load_data(json.loads(input_file.readline().strip()))
232 # If this is a on-modify event, we are provided with additional
233 # line of input, which provides updated data
235 task._update_data(json.loads(input_file.readline().strip()))
239 def __init__(self, warrior, **kwargs):
240 self.warrior = warrior
242 # Check that user is not able to set read-only value in __init__
243 for key in kwargs.keys():
244 if key in self.read_only_fields:
245 raise RuntimeError('Field \'%s\' is read-only' % key)
247 # We serialize the data in kwargs so that users of the library
248 # do not have to pass different data formats via __setitem__ and
249 # __init__ methods, that would be confusing
251 # Rather unfortunate syntax due to python2.6 comaptiblity
252 self._load_data(dict((key, self._serialize(key, value))
253 for (key, value) in six.iteritems(kwargs)))
255 def __unicode__(self):
256 return self['description']
258 def __eq__(self, other):
259 if self['uuid'] and other['uuid']:
260 # For saved Tasks, just define equality by equality of uuids
261 return self['uuid'] == other['uuid']
263 # If the tasks are not saved, compare the actual instances
264 return id(self) == id(other)
269 # For saved Tasks, just define equality by equality of uuids
270 return self['uuid'].__hash__()
272 # If the tasks are not saved, return hash of instance id
273 return id(self).__hash__()
276 def _modified_fields(self):
277 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
278 for key in writable_fields:
279 if self._data.get(key) != self._original_data.get(key):
284 return bool(list(self._modified_fields))
288 return self['status'] == six.text_type('completed')
292 return self['status'] == six.text_type('deleted')
296 return self['status'] == six.text_type('waiting')
300 return self['status'] == six.text_type('pending')
304 return self['uuid'] is not None or self['id'] is not None
306 def serialize_depends(self, cur_dependencies):
307 # Check that all the tasks are saved
308 for task in cur_dependencies:
310 raise Task.NotSaved('Task \'%s\' needs to be saved before '
311 'it can be set as dependency.' % task)
313 return super(Task, self).serialize_depends(cur_dependencies)
315 def format_depends(self):
316 # We need to generate added and removed dependencies list,
317 # since Taskwarrior does not accept redefining dependencies.
319 # This cannot be part of serialize_depends, since we need
320 # to keep a list of all depedencies in the _data dictionary,
321 # not just currently added/removed ones
323 old_dependencies = self._original_data.get('depends', set())
325 added = self['depends'] - old_dependencies
326 removed = old_dependencies - self['depends']
328 # Removed dependencies need to be prefixed with '-'
329 return 'depends:' + ','.join(
330 [t['uuid'] for t in added] +
331 ['-' + t['uuid'] for t in removed]
334 def format_description(self):
335 # Task version older than 2.4.0 ignores first word of the
336 # task description if description: prefix is used
337 if self.warrior.version < VERSION_2_4_0:
338 return self._data['description']
340 return "description:'{0}'".format(self._data['description'] or '')
344 raise Task.NotSaved("Task needs to be saved before it can be deleted")
346 # Refresh the status, and raise exception if the task is deleted
347 self.refresh(only_fields=['status'])
350 raise Task.DeletedTask("Task was already deleted")
352 self.warrior.execute_command([self['uuid'], 'delete'])
354 # Refresh the status again, so that we have updated info stored
355 self.refresh(only_fields=['status'])
360 raise Task.NotSaved("Task needs to be saved before it can be completed")
362 # Refresh, and raise exception if task is already completed/deleted
363 self.refresh(only_fields=['status'])
366 raise Task.CompletedTask("Cannot complete a completed task")
368 raise Task.DeletedTask("Deleted task cannot be completed")
370 self.warrior.execute_command([self['uuid'], 'done'])
372 # Refresh the status again, so that we have updated info stored
373 self.refresh(only_fields=['status'])
376 if self.saved and not self.modified:
379 args = [self['uuid'], 'modify'] if self.saved else ['add']
380 args.extend(self._get_modified_fields_as_args())
381 output = self.warrior.execute_command(args)
383 # Parse out the new ID, if the task is being added for the first time
385 id_lines = [l for l in output if l.startswith('Created task ')]
387 # Complain loudly if it seems that more tasks were created
389 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
390 raise TaskWarriorException("Unexpected output when creating "
391 "task: %s" % '\n'.join(id_lines))
393 # Circumvent the ID storage, since ID is considered read-only
394 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
396 # Refreshing is very important here, as not only modification time
397 # is updated, but arbitrary attribute may have changed due hooks
398 # altering the data before saving
401 def add_annotation(self, annotation):
403 raise Task.NotSaved("Task needs to be saved to add annotation")
405 args = [self['uuid'], 'annotate', annotation]
406 self.warrior.execute_command(args)
407 self.refresh(only_fields=['annotations'])
409 def remove_annotation(self, annotation):
411 raise Task.NotSaved("Task needs to be saved to remove annotation")
413 if isinstance(annotation, TaskAnnotation):
414 annotation = annotation['description']
415 args = [self['uuid'], 'denotate', annotation]
416 self.warrior.execute_command(args)
417 self.refresh(only_fields=['annotations'])
419 def _get_modified_fields_as_args(self):
422 def add_field(field):
423 # Add the output of format_field method to args list (defaults to
425 serialized_value = self._serialize(field, self._data[field]) or ''
426 format_default = lambda: "{0}:{1}".format(
428 "'{0}'".format(serialized_value) if serialized_value else ''
430 format_func = getattr(self, 'format_{0}'.format(field),
432 args.append(format_func())
434 # If we're modifying saved task, simply pass on all modified fields
436 for field in self._modified_fields:
438 # For new tasks, pass all fields that make sense
440 for field in self._data.keys():
441 if field in self.read_only_fields:
447 def refresh(self, only_fields=[]):
448 # Raise error when trying to refresh a task that has not been saved
450 raise Task.NotSaved("Task needs to be saved to be refreshed")
452 # We need to use ID as backup for uuid here for the refreshes
453 # of newly saved tasks. Any other place in the code is fine
454 # with using UUID only.
455 args = [self['uuid'] or self['id'], 'export']
456 new_data = json.loads(self.warrior.execute_command(args)[0])
459 [(k, new_data.get(k)) for k in only_fields])
460 self._update_data(to_update, update_original=True)
462 self._load_data(new_data)
464 def export_data(self):
466 Exports current data contained in the Task as JSON
469 # We need to remove spaces for TW-1504, use custom separators
470 return json.dumps(self._data, separators=(',',':'))
472 class TaskFilter(SerializingObject):
474 A set of parameters to filter the task list with.
477 def __init__(self, filter_params=[]):
478 self.filter_params = filter_params
480 def add_filter(self, filter_str):
481 self.filter_params.append(filter_str)
483 def add_filter_param(self, key, value):
484 key = key.replace('__', '.')
486 # Replace the value with empty string, since that is the
487 # convention in TW for empty values
488 attribute_key = key.split('.')[0]
489 value = self._serialize(attribute_key, value)
491 # If we are filtering by uuid:, do not use uuid keyword
494 self.filter_params.insert(0, value)
496 # Surround value with aphostrophes unless it's a empty string
497 value = "'%s'" % value if value else ''
499 # We enforce equality match by using 'is' (or 'none') modifier
500 # Without using this syntax, filter fails due to TW-1479
501 modifier = '.is' if value else '.none'
502 key = key + modifier if '.' not in key else key
504 self.filter_params.append("{0}:{1}".format(key, value))
506 def get_filter_params(self):
507 return [f for f in self.filter_params if f]
511 c.filter_params = list(self.filter_params)
515 class TaskQuerySet(object):
517 Represents a lazy lookup for a task objects.
520 def __init__(self, warrior=None, filter_obj=None):
521 self.warrior = warrior
522 self._result_cache = None
523 self.filter_obj = filter_obj or TaskFilter()
525 def __deepcopy__(self, memo):
527 Deep copy of a QuerySet doesn't populate the cache
529 obj = self.__class__()
530 for k, v in self.__dict__.items():
531 if k in ('_iter', '_result_cache'):
532 obj.__dict__[k] = None
534 obj.__dict__[k] = copy.deepcopy(v, memo)
538 data = list(self[:REPR_OUTPUT_SIZE + 1])
539 if len(data) > REPR_OUTPUT_SIZE:
540 data[-1] = "...(remaining elements truncated)..."
544 if self._result_cache is None:
545 self._result_cache = list(self)
546 return len(self._result_cache)
549 if self._result_cache is None:
550 self._result_cache = self._execute()
551 return iter(self._result_cache)
553 def __getitem__(self, k):
554 if self._result_cache is None:
555 self._result_cache = list(self)
556 return self._result_cache.__getitem__(k)
559 if self._result_cache is not None:
560 return bool(self._result_cache)
563 except StopIteration:
567 def __nonzero__(self):
568 return type(self).__bool__(self)
570 def _clone(self, klass=None, **kwargs):
572 klass = self.__class__
573 filter_obj = self.filter_obj.clone()
574 c = klass(warrior=self.warrior, filter_obj=filter_obj)
575 c.__dict__.update(kwargs)
580 Fetch the tasks which match the current filters.
582 return self.warrior.filter_tasks(self.filter_obj)
586 Returns a new TaskQuerySet that is a copy of the current one.
591 return self.filter(status=PENDING)
594 return self.filter(status=COMPLETED)
596 def filter(self, *args, **kwargs):
598 Returns a new TaskQuerySet with the given filters added.
600 clone = self._clone()
602 clone.filter_obj.add_filter(f)
603 for key, value in kwargs.items():
604 clone.filter_obj.add_filter_param(key, value)
607 def get(self, **kwargs):
609 Performs the query and returns a single object matching the given
612 clone = self.filter(**kwargs)
615 return clone._result_cache[0]
617 raise Task.DoesNotExist(
618 'Task matching query does not exist. '
619 'Lookup parameters were {0}'.format(kwargs))
621 'get() returned more than one Task -- it returned {0}! '
622 'Lookup parameters were {1}'.format(num, kwargs))
625 class TaskWarrior(object):
626 def __init__(self, data_location='~/.task', create=True):
627 data_location = os.path.expanduser(data_location)
628 if create and not os.path.exists(data_location):
629 os.makedirs(data_location)
631 'data.location': os.path.expanduser(data_location),
632 'confirmation': 'no',
633 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
635 self.tasks = TaskQuerySet(self)
636 self.version = self._get_version()
638 def _get_command_args(self, args, config_override={}):
639 command_args = ['task', 'rc:/']
640 config = self.config.copy()
641 config.update(config_override)
642 for item in config.items():
643 command_args.append('rc.{0}={1}'.format(*item))
644 command_args.extend(map(str, args))
647 def _get_version(self):
648 p = subprocess.Popen(
649 ['task', '--version'],
650 stdout=subprocess.PIPE,
651 stderr=subprocess.PIPE)
652 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
653 return stdout.strip('\n')
655 def execute_command(self, args, config_override={}):
656 command_args = self._get_command_args(
657 args, config_override=config_override)
658 logger.debug(' '.join(command_args))
659 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
660 stderr=subprocess.PIPE)
661 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
664 error_msg = stderr.strip().splitlines()[-1]
666 error_msg = stdout.strip()
667 raise TaskWarriorException(error_msg)
668 return stdout.strip().split('\n')
670 def filter_tasks(self, filter_obj):
671 args = ['export', '--'] + filter_obj.get_filter_params()
673 for line in self.execute_command(args):
675 data = line.strip(',')
677 filtered_task = Task(self)
678 filtered_task._load_data(json.loads(data))
679 tasks.append(filtered_task)
681 raise TaskWarriorException('Invalid JSON: %s' % data)
684 def merge_with(self, path, push=False):
685 path = path.rstrip('/') + '/'
686 self.execute_command(['merge', path], config_override={
687 'merge.autopush': 'yes' if push else 'no',
691 self.execute_command(['undo'])