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
13 DATE_FORMAT = '%Y%m%dT%H%M%SZ'
16 COMPLETED = 'completed'
18 VERSION_2_1_0 = six.u('2.1.0')
19 VERSION_2_2_0 = six.u('2.2.0')
20 VERSION_2_3_0 = six.u('2.3.0')
21 VERSION_2_4_0 = six.u('2.4.0')
23 logger = logging.getLogger(__name__)
24 local_zone = tzlocal.get_localzone()
27 class TaskWarriorException(Exception):
31 class SerializingObject(object):
33 Common ancestor for TaskResource & TaskFilter, since they both
34 need to serialize arguments.
36 Serializing method should hold the following contract:
37 - any empty value (meaning removal of the attribute)
38 is deserialized into a empty string
39 - None denotes a empty value for any attribute
41 Deserializing method should hold the following contract:
42 - None denotes a empty value for any attribute (however,
43 this is here as a safeguard, TaskWarrior currently does
44 not export empty-valued attributes) if the attribute
45 is not iterable (e.g. list or set), in which case
46 a empty iterable should be used.
49 def _deserialize(self, key, value):
50 hydrate_func = getattr(self, 'deserialize_{0}'.format(key),
51 lambda x: x if x != '' else None)
52 return hydrate_func(value)
54 def _serialize(self, key, value):
55 dehydrate_func = getattr(self, 'serialize_{0}'.format(key),
56 lambda x: x if x is not None else '')
57 return dehydrate_func(value)
59 def _normalize(self, key, value):
61 Use normalize_<key> methods to normalize user input. Any user
62 input will be normalized at the moment it is used as filter,
63 or entered as a value of Task attribute.
66 normalize_func = getattr(self, 'normalize_{0}'.format(key),
69 return normalize_func(value)
71 def timestamp_serializer(self, date):
75 # Any serialized timestamp should be localized, we need to
76 # convert to UTC before converting to string (DATE_FORMAT uses UTC)
77 date = date.astimezone(pytz.utc)
79 return date.strftime(DATE_FORMAT)
81 def timestamp_deserializer(self, date_str):
85 # Return timestamp localized in the local zone
86 naive_timestamp = datetime.datetime.strptime(date_str, DATE_FORMAT)
87 localized_timestamp = pytz.utc.localize(naive_timestamp)
88 return localized_timestamp.astimezone(local_zone)
90 def serialize_entry(self, value):
91 return self.timestamp_serializer(value)
93 def deserialize_entry(self, value):
94 return self.timestamp_deserializer(value)
96 def serialize_modified(self, value):
97 return self.timestamp_serializer(value)
99 def deserialize_modified(self, value):
100 return self.timestamp_deserializer(value)
102 def serialize_due(self, value):
103 return self.timestamp_serializer(value)
105 def deserialize_due(self, value):
106 return self.timestamp_deserializer(value)
108 def serialize_scheduled(self, value):
109 return self.timestamp_serializer(value)
111 def deserialize_scheduled(self, value):
112 return self.timestamp_deserializer(value)
114 def serialize_until(self, value):
115 return self.timestamp_serializer(value)
117 def deserialize_until(self, value):
118 return self.timestamp_deserializer(value)
120 def serialize_wait(self, value):
121 return self.timestamp_serializer(value)
123 def deserialize_wait(self, value):
124 return self.timestamp_deserializer(value)
126 def serialize_annotations(self, value):
127 value = value if value is not None else []
129 # This may seem weird, but it's correct, we want to export
130 # a list of dicts as serialized value
131 serialized_annotations = [json.loads(annotation.export_data())
132 for annotation in value]
133 return serialized_annotations if serialized_annotations else ''
135 def deserialize_annotations(self, data):
136 return [TaskAnnotation(self, d) for d in data] if data else []
138 def serialize_tags(self, tags):
139 return ','.join(tags) if tags else ''
141 def deserialize_tags(self, tags):
142 if isinstance(tags, six.string_types):
143 return tags.split(',') if tags else []
146 def serialize_depends(self, value):
147 # Return the list of uuids
148 value = value if value is not None else set()
149 return ','.join(task['uuid'] for task in value)
151 def deserialize_depends(self, raw_uuids):
152 raw_uuids = raw_uuids or '' # Convert None to empty string
153 uuids = raw_uuids.split(',')
154 return set(self.warrior.tasks.get(uuid=uuid) for uuid in uuids if uuid)
156 def normalize_datetime(self, value):
158 Normalizes date/datetime value (considered to come from user input)
159 to localized datetime value. Following conversions happen:
161 naive date -> localized datetime with the same date, and time=midnight
162 naive datetime -> localized datetime with the same value
163 localized datetime -> localized datetime (no conversion)
166 if (isinstance(value, datetime.date)
167 and not isinstance(value, datetime.datetime)):
168 # Convert to local midnight
169 value_full = datetime.datetime.combine(value, datetime.time.min)
170 localized = local_zone.localize(value_full)
171 elif isinstance(value, datetime.datetime) and value.tzinfo is None:
172 # Convert to localized datetime object
173 localized = local_zone.localize(value)
175 # If the value is already localized, there is no need to change
176 # time zone at this point. Also None is a valid value too.
183 class TaskResource(SerializingObject):
184 read_only_fields = []
186 def _load_data(self, data):
187 self._data = dict((key, self._deserialize(key, value))
188 for key, value in data.items())
189 # We need to use a copy for original data, so that changes
190 # are not propagated.
191 self._original_data = copy.deepcopy(self._data)
193 def _update_data(self, data, update_original=False):
195 Low level update of the internal _data dict. Data which are coming as
196 updates should already be serialized. If update_original is True, the
197 original_data dict is updated as well.
199 self._data.update(dict((key, self._deserialize(key, value))
200 for key, value in data.items()))
203 self._original_data = copy.deepcopy(self._data)
206 def __getitem__(self, key):
207 # This is a workaround to make TaskResource non-iterable
208 # over simple index-based iteration
215 if key not in self._data:
216 self._data[key] = self._deserialize(key, None)
218 return self._data.get(key)
220 def __setitem__(self, key, value):
221 if key in self.read_only_fields:
222 raise RuntimeError('Field \'%s\' is read-only' % key)
224 # Localize any naive date/datetime to the detected timezone
225 if (isinstance(value, datetime.datetime) or
226 isinstance(value, datetime.date)):
227 value = self.normalize_datetime(value)
229 self._data[key] = value
232 s = six.text_type(self.__unicode__())
234 s = s.encode('utf-8')
240 def export_data(self):
242 Exports current data contained in the Task as JSON
245 # We need to remove spaces for TW-1504, use custom separators
246 data_tuples = ((key, self._serialize(key, value))
247 for key, value in six.iteritems(self._data))
249 # Empty string denotes empty serialized value, we do not want
250 # to pass that to TaskWarrior.
251 data_tuples = filter(lambda t: t[1] is not '', data_tuples)
252 data = dict(data_tuples)
253 return json.dumps(data, separators=(',',':'))
256 def _modified_fields(self):
257 writable_fields = set(self._data.keys()) - set(self.read_only_fields)
258 for key in writable_fields:
259 new_value = self._data.get(key)
260 old_value = self._original_data.get(key)
262 # Make sure not to mark data removal as modified field if the
263 # field originally had some empty value
264 if key in self._data and not new_value and not old_value:
267 if new_value != old_value:
272 return bool(list(self._modified_fields))
275 class TaskAnnotation(TaskResource):
276 read_only_fields = ['entry', 'description']
278 def __init__(self, task, data={}):
280 self._load_data(data)
283 self.task.remove_annotation(self)
285 def __unicode__(self):
286 return self['description']
288 def __eq__(self, other):
289 # consider 2 annotations equal if they belong to the same task, and
290 # their data dics are the same
291 return self.task == other.task and self._data == other._data
293 __repr__ = __unicode__
296 class Task(TaskResource):
297 read_only_fields = ['id', 'entry', 'urgency', 'uuid', 'modified']
299 class DoesNotExist(Exception):
302 class CompletedTask(Exception):
304 Raised when the operation cannot be performed on the completed task.
308 class DeletedTask(Exception):
310 Raised when the operation cannot be performed on the deleted task.
314 class NotSaved(Exception):
316 Raised when the operation cannot be performed on the task, because
317 it has not been saved to TaskWarrior yet.
322 def from_input(cls, input_file=sys.stdin, modify=None):
324 Creates a Task object, directly from the stdin, by reading one line.
325 If modify=True, two lines are used, first line interpreted as the
326 original state of the Task object, and second line as its new,
327 modified value. This is consistent with the TaskWarrior's hook
330 Object created by this method should not be saved, deleted
331 or refreshed, as t could create a infinite loop. For this
332 reason, TaskWarrior instance is set to None.
334 Input_file argument can be used to specify the input file,
335 but defaults to sys.stdin.
338 # TaskWarrior instance is set to None
341 # Detect the hook type if not given directly
342 name = os.path.basename(sys.argv[0])
343 modify = name.startswith('on-modify') if modify is None else modify
345 # Load the data from the input
346 task._load_data(json.loads(input_file.readline().strip()))
348 # If this is a on-modify event, we are provided with additional
349 # line of input, which provides updated data
351 task._update_data(json.loads(input_file.readline().strip()))
355 def __init__(self, warrior, **kwargs):
356 self.warrior = warrior
358 # Check that user is not able to set read-only value in __init__
359 for key in kwargs.keys():
360 if key in self.read_only_fields:
361 raise RuntimeError('Field \'%s\' is read-only' % key)
363 # We serialize the data in kwargs so that users of the library
364 # do not have to pass different data formats via __setitem__ and
365 # __init__ methods, that would be confusing
367 # Rather unfortunate syntax due to python2.6 comaptiblity
368 self._data = dict((key, self._normalize(key, value))
369 for (key, value) in six.iteritems(kwargs))
370 self._original_data = copy.deepcopy(self._data)
372 def __unicode__(self):
373 return self['description']
375 def __eq__(self, other):
376 if self['uuid'] and other['uuid']:
377 # For saved Tasks, just define equality by equality of uuids
378 return self['uuid'] == other['uuid']
380 # If the tasks are not saved, compare the actual instances
381 return id(self) == id(other)
386 # For saved Tasks, just define equality by equality of uuids
387 return self['uuid'].__hash__()
389 # If the tasks are not saved, return hash of instance id
390 return id(self).__hash__()
394 return self['status'] == six.text_type('completed')
398 return self['status'] == six.text_type('deleted')
402 return self['status'] == six.text_type('waiting')
406 return self['status'] == six.text_type('pending')
410 return self['uuid'] is not None or self['id'] is not None
412 def serialize_depends(self, cur_dependencies):
413 # Check that all the tasks are saved
414 for task in (cur_dependencies or set()):
416 raise Task.NotSaved('Task \'%s\' needs to be saved before '
417 'it can be set as dependency.' % task)
419 return super(Task, self).serialize_depends(cur_dependencies)
421 def format_depends(self):
422 # We need to generate added and removed dependencies list,
423 # since Taskwarrior does not accept redefining dependencies.
425 # This cannot be part of serialize_depends, since we need
426 # to keep a list of all depedencies in the _data dictionary,
427 # not just currently added/removed ones
429 old_dependencies = self._original_data.get('depends', set())
431 added = self['depends'] - old_dependencies
432 removed = old_dependencies - self['depends']
434 # Removed dependencies need to be prefixed with '-'
435 return 'depends:' + ','.join(
436 [t['uuid'] for t in added] +
437 ['-' + t['uuid'] for t in removed]
440 def format_description(self):
441 # Task version older than 2.4.0 ignores first word of the
442 # task description if description: prefix is used
443 if self.warrior.version < VERSION_2_4_0:
444 return self._data['description']
446 return "description:'{0}'".format(self._data['description'] or '')
450 raise Task.NotSaved("Task needs to be saved before it can be deleted")
452 # Refresh the status, and raise exception if the task is deleted
453 self.refresh(only_fields=['status'])
456 raise Task.DeletedTask("Task was already deleted")
458 self.warrior.execute_command([self['uuid'], 'delete'])
460 # Refresh the status again, so that we have updated info stored
461 self.refresh(only_fields=['status'])
466 raise Task.NotSaved("Task needs to be saved before it can be completed")
468 # Refresh, and raise exception if task is already completed/deleted
469 self.refresh(only_fields=['status'])
472 raise Task.CompletedTask("Cannot complete a completed task")
474 raise Task.DeletedTask("Deleted task cannot be completed")
476 self.warrior.execute_command([self['uuid'], 'done'])
478 # Refresh the status again, so that we have updated info stored
479 self.refresh(only_fields=['status'])
482 if self.saved and not self.modified:
485 args = [self['uuid'], 'modify'] if self.saved else ['add']
486 args.extend(self._get_modified_fields_as_args())
487 output = self.warrior.execute_command(args)
489 # Parse out the new ID, if the task is being added for the first time
491 id_lines = [l for l in output if l.startswith('Created task ')]
493 # Complain loudly if it seems that more tasks were created
495 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
496 raise TaskWarriorException("Unexpected output when creating "
497 "task: %s" % '\n'.join(id_lines))
499 # Circumvent the ID storage, since ID is considered read-only
500 self._data['id'] = int(id_lines[0].split(' ')[2].rstrip('.'))
502 # Refreshing is very important here, as not only modification time
503 # is updated, but arbitrary attribute may have changed due hooks
504 # altering the data before saving
507 def add_annotation(self, annotation):
509 raise Task.NotSaved("Task needs to be saved to add annotation")
511 args = [self['uuid'], 'annotate', annotation]
512 self.warrior.execute_command(args)
513 self.refresh(only_fields=['annotations'])
515 def remove_annotation(self, annotation):
517 raise Task.NotSaved("Task needs to be saved to remove annotation")
519 if isinstance(annotation, TaskAnnotation):
520 annotation = annotation['description']
521 args = [self['uuid'], 'denotate', annotation]
522 self.warrior.execute_command(args)
523 self.refresh(only_fields=['annotations'])
525 def _get_modified_fields_as_args(self):
528 def add_field(field):
529 # Add the output of format_field method to args list (defaults to
531 serialized_value = self._serialize(field, self._data[field])
533 # Empty values should not be enclosed in quotation marks, see
535 if serialized_value is '':
536 escaped_serialized_value = ''
538 escaped_serialized_value = "'{0}'".format(serialized_value)
540 format_default = lambda: "{0}:{1}".format(field,
541 escaped_serialized_value)
543 format_func = getattr(self, 'format_{0}'.format(field),
546 args.append(format_func())
548 # If we're modifying saved task, simply pass on all modified fields
550 for field in self._modified_fields:
552 # For new tasks, pass all fields that make sense
554 for field in self._data.keys():
555 if field in self.read_only_fields:
561 def refresh(self, only_fields=[]):
562 # Raise error when trying to refresh a task that has not been saved
564 raise Task.NotSaved("Task needs to be saved to be refreshed")
566 # We need to use ID as backup for uuid here for the refreshes
567 # of newly saved tasks. Any other place in the code is fine
568 # with using UUID only.
569 args = [self['uuid'] or self['id'], 'export']
570 new_data = json.loads(self.warrior.execute_command(args)[0])
573 [(k, new_data.get(k)) for k in only_fields])
574 self._update_data(to_update, update_original=True)
576 self._load_data(new_data)
578 class TaskFilter(SerializingObject):
580 A set of parameters to filter the task list with.
583 def __init__(self, filter_params=[]):
584 self.filter_params = filter_params
586 def add_filter(self, filter_str):
587 self.filter_params.append(filter_str)
589 def add_filter_param(self, key, value):
590 key = key.replace('__', '.')
592 # Replace the value with empty string, since that is the
593 # convention in TW for empty values
594 attribute_key = key.split('.')[0]
596 # Since this is user input, we need to normalize datetime
598 if (isinstance(value, datetime.datetime) or
599 isinstance(value, datetime.date)):
600 value = self.normalize_datetime(value)
602 value = self._serialize(attribute_key, value)
604 # If we are filtering by uuid:, do not use uuid keyword
607 self.filter_params.insert(0, value)
609 # Surround value with aphostrophes unless it's a empty string
610 value = "'%s'" % value if value else ''
612 # We enforce equality match by using 'is' (or 'none') modifier
613 # Without using this syntax, filter fails due to TW-1479
614 modifier = '.is' if value else '.none'
615 key = key + modifier if '.' not in key else key
617 self.filter_params.append("{0}:{1}".format(key, value))
619 def get_filter_params(self):
620 return [f for f in self.filter_params if f]
624 c.filter_params = list(self.filter_params)
628 class TaskQuerySet(object):
630 Represents a lazy lookup for a task objects.
633 def __init__(self, warrior=None, filter_obj=None):
634 self.warrior = warrior
635 self._result_cache = None
636 self.filter_obj = filter_obj or TaskFilter()
638 def __deepcopy__(self, memo):
640 Deep copy of a QuerySet doesn't populate the cache
642 obj = self.__class__()
643 for k, v in self.__dict__.items():
644 if k in ('_iter', '_result_cache'):
645 obj.__dict__[k] = None
647 obj.__dict__[k] = copy.deepcopy(v, memo)
651 data = list(self[:REPR_OUTPUT_SIZE + 1])
652 if len(data) > REPR_OUTPUT_SIZE:
653 data[-1] = "...(remaining elements truncated)..."
657 if self._result_cache is None:
658 self._result_cache = list(self)
659 return len(self._result_cache)
662 if self._result_cache is None:
663 self._result_cache = self._execute()
664 return iter(self._result_cache)
666 def __getitem__(self, k):
667 if self._result_cache is None:
668 self._result_cache = list(self)
669 return self._result_cache.__getitem__(k)
672 if self._result_cache is not None:
673 return bool(self._result_cache)
676 except StopIteration:
680 def __nonzero__(self):
681 return type(self).__bool__(self)
683 def _clone(self, klass=None, **kwargs):
685 klass = self.__class__
686 filter_obj = self.filter_obj.clone()
687 c = klass(warrior=self.warrior, filter_obj=filter_obj)
688 c.__dict__.update(kwargs)
693 Fetch the tasks which match the current filters.
695 return self.warrior.filter_tasks(self.filter_obj)
699 Returns a new TaskQuerySet that is a copy of the current one.
704 return self.filter(status=PENDING)
707 return self.filter(status=COMPLETED)
709 def filter(self, *args, **kwargs):
711 Returns a new TaskQuerySet with the given filters added.
713 clone = self._clone()
715 clone.filter_obj.add_filter(f)
716 for key, value in kwargs.items():
717 clone.filter_obj.add_filter_param(key, value)
720 def get(self, **kwargs):
722 Performs the query and returns a single object matching the given
725 clone = self.filter(**kwargs)
728 return clone._result_cache[0]
730 raise Task.DoesNotExist(
731 'Task matching query does not exist. '
732 'Lookup parameters were {0}'.format(kwargs))
734 'get() returned more than one Task -- it returned {0}! '
735 'Lookup parameters were {1}'.format(num, kwargs))
738 class TaskWarrior(object):
739 def __init__(self, data_location='~/.task', create=True):
740 data_location = os.path.expanduser(data_location)
741 if create and not os.path.exists(data_location):
742 os.makedirs(data_location)
744 'data.location': os.path.expanduser(data_location),
745 'confirmation': 'no',
746 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
747 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
749 self.tasks = TaskQuerySet(self)
750 self.version = self._get_version()
752 def _get_command_args(self, args, config_override={}):
753 command_args = ['task', 'rc:/']
754 config = self.config.copy()
755 config.update(config_override)
756 for item in config.items():
757 command_args.append('rc.{0}={1}'.format(*item))
758 command_args.extend(map(str, args))
761 def _get_version(self):
762 p = subprocess.Popen(
763 ['task', '--version'],
764 stdout=subprocess.PIPE,
765 stderr=subprocess.PIPE)
766 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
767 return stdout.strip('\n')
769 def execute_command(self, args, config_override={}):
770 command_args = self._get_command_args(
771 args, config_override=config_override)
772 logger.debug(' '.join(command_args))
773 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
774 stderr=subprocess.PIPE)
775 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
778 error_msg = stderr.strip().splitlines()[-1]
780 error_msg = stdout.strip()
781 raise TaskWarriorException(error_msg)
782 return stdout.strip().split('\n')
784 def filter_tasks(self, filter_obj):
785 args = ['export', '--'] + filter_obj.get_filter_params()
787 for line in self.execute_command(args):
789 data = line.strip(',')
791 filtered_task = Task(self)
792 filtered_task._load_data(json.loads(data))
793 tasks.append(filtered_task)
795 raise TaskWarriorException('Invalid JSON: %s' % data)
798 def merge_with(self, path, push=False):
799 path = path.rstrip('/') + '/'
800 self.execute_command(['merge', path], config_override={
801 'merge.autopush': 'yes' if push else 'no',
805 self.execute_command(['undo'])