X-Git-Url: https://git.madduck.net/etc/taskwarrior.git/blobdiff_plain/2ef4751c25fbbced996deed6e17bd5bae7a5fdd7..919f61adb63e967d458122b6685f678a5fa0249f:/tasklib/task.py diff --git a/tasklib/task.py b/tasklib/task.py index 2a51b35..ad27a92 100644 --- a/tasklib/task.py +++ b/tasklib/task.py @@ -11,6 +11,7 @@ import subprocess import tzlocal DATE_FORMAT = '%Y%m%dT%H%M%SZ' +DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S' REPR_OUTPUT_SIZE = 10 PENDING = 'pending' COMPLETED = 'completed' @@ -19,6 +20,8 @@ VERSION_2_1_0 = six.u('2.1.0') VERSION_2_2_0 = six.u('2.2.0') VERSION_2_3_0 = six.u('2.3.0') VERSION_2_4_0 = six.u('2.4.0') +VERSION_2_4_1 = six.u('2.4.1') +VERSION_2_4_2 = six.u('2.4.2') logger = logging.getLogger(__name__) local_zone = tzlocal.get_localzone() @@ -28,6 +31,37 @@ class TaskWarriorException(Exception): pass +class ReadOnlyDictView(object): + """ + Provides simplified read-only view upon dict object. + """ + + def __init__(self, viewed_dict): + self.viewed_dict = viewed_dict + + def __getitem__(self, key): + return copy.deepcopy(self.viewed_dict.__getitem__(key)) + + def __contains__(self, k): + return self.viewed_dict.__contains__(k) + + def __iter__(self): + for value in self.viewed_dict: + yield copy.deepcopy(value) + + def __len__(self): + return len(self.viewed_dict) + + def get(self, key, default=None): + return copy.deepcopy(self.viewed_dict.get(key, default)) + + def items(self): + return [copy.deepcopy(v) for v in self.viewed_dict.items()] + + def values(self): + return [copy.deepcopy(v) for v in self.viewed_dict.values()] + + class SerializingObject(object): """ Common ancestor for TaskResource & TaskFilter, since they both @@ -44,6 +78,15 @@ class SerializingObject(object): not export empty-valued attributes) if the attribute is not iterable (e.g. list or set), in which case a empty iterable should be used. + + Normalizing methods should hold the following contract: + - They are used to validate and normalize the user input. + Any attribute value that comes from the user (during Task + initialization, assignign values to Task attributes, or + filtering by user-provided values of attributes) is first + validated and normalized using the normalize_{key} method. + - If validation or normalization fails, normalizer is expected + to raise ValueError. """ def _deserialize(self, key, value): @@ -63,6 +106,10 @@ class SerializingObject(object): or entered as a value of Task attribute. """ + # None value should not be converted by normalizer + if value is None: + return None + normalize_func = getattr(self, 'normalize_{0}'.format(key), lambda x: x) @@ -105,6 +152,24 @@ class SerializingObject(object): def normalize_modified(self, value): return self.datetime_normalizer(value) + def serialize_start(self, value): + return self.timestamp_serializer(value) + + def deserialize_start(self, value): + return self.timestamp_deserializer(value) + + def normalize_start(self, value): + return self.datetime_normalizer(value) + + def serialize_end(self, value): + return self.timestamp_serializer(value) + + def deserialize_end(self, value): + return self.timestamp_deserializer(value) + + def normalize_end(self, value): + return self.datetime_normalizer(value) + def serialize_due(self, value): return self.timestamp_serializer(value) @@ -186,16 +251,34 @@ class SerializingObject(object): # Convert to local midnight value_full = datetime.datetime.combine(value, datetime.time.min) localized = local_zone.localize(value_full) - elif isinstance(value, datetime.datetime) and value.tzinfo is None: - # Convert to localized datetime object - localized = local_zone.localize(value) + elif isinstance(value, datetime.datetime): + if value.tzinfo is None: + # Convert to localized datetime object + localized = local_zone.localize(value) + else: + # If the value is already localized, there is no need to change + # time zone at this point. Also None is a valid value too. + localized = value + elif isinstance(value, six.string_types): + # For strings, use 'task calc' to evaluate the string to datetime + args = value.split() + result = self.warrior.execute_command(['calc'] + args) + naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC) + localized = local_zone.localize(naive) else: - # If the value is already localized, there is no need to change - # time zone at this point. Also None is a valid value too. - localized = value - + raise ValueError("Provided value could not be converted to " + "datetime, its type is not supported: {}" + .format(type(value))) + return localized - + + def normalize_uuid(self, value): + # Enforce sane UUID + if not isinstance(value, six.string_types) or value == '': + raise ValueError("UUID must be a valid non-empty string, " + "not: {}".format(value)) + + return value class TaskResource(SerializingObject): @@ -334,7 +417,7 @@ class Task(TaskResource): pass @classmethod - def from_input(cls, input_file=sys.stdin, modify=None): + def from_input(cls, input_file=sys.stdin, modify=None, warrior=None): """ Creates a Task object, directly from the stdin, by reading one line. If modify=True, two lines are used, first line interpreted as the @@ -350,13 +433,18 @@ class Task(TaskResource): but defaults to sys.stdin. """ - # TaskWarrior instance is set to None - task = cls(None) - # Detect the hook type if not given directly name = os.path.basename(sys.argv[0]) modify = name.startswith('on-modify') if modify is None else modify + # Create the TaskWarrior instance if none passed + if warrior is None: + hook_parent_dir = os.path.dirname(os.path.dirname(sys.argv[0])) + warrior = TaskWarrior(data_location=hook_parent_dir) + + # TaskWarrior instance is set to None + task = cls(warrior) + # Load the data from the input task._load_data(json.loads(input_file.readline().strip())) @@ -384,6 +472,9 @@ class Task(TaskResource): for (key, value) in six.iteritems(kwargs)) self._original_data = copy.deepcopy(self._data) + # Provide read only access to the original data + self.original = ReadOnlyDictView(self._original_data) + def __unicode__(self): return self['description'] @@ -473,8 +564,24 @@ class Task(TaskResource): self.warrior.execute_command([self['uuid'], 'delete']) # Refresh the status again, so that we have updated info stored + self.refresh(only_fields=['status', 'start', 'end']) + + def start(self): + if not self.saved: + raise Task.NotSaved("Task needs to be saved before it can be started") + + # Refresh, and raise exception if task is already completed/deleted self.refresh(only_fields=['status']) + if self.completed: + raise Task.CompletedTask("Cannot start a completed task") + elif self.deleted: + raise Task.DeletedTask("Deleted task cannot be started") + + self.warrior.execute_command([self['uuid'], 'start']) + + # Refresh the status again, so that we have updated info stored + self.refresh(only_fields=['status', 'start']) def done(self): if not self.saved: @@ -491,7 +598,7 @@ class Task(TaskResource): self.warrior.execute_command([self['uuid'], 'done']) # Refresh the status again, so that we have updated info stored - self.refresh(only_fields=['status']) + self.refresh(only_fields=['status', 'start', 'end']) def save(self): if self.saved and not self.modified: @@ -609,7 +716,7 @@ class TaskFilter(SerializingObject): attribute_key = key.split('.')[0] # Since this is user input, we need to normalize before we serialize - value = self._normalize(key, value) + value = self._normalize(attribute_key, value) value = self._serialize(attribute_key, value) # If we are filtering by uuid:, do not use uuid keyword @@ -747,12 +854,20 @@ class TaskQuerySet(object): class TaskWarrior(object): - def __init__(self, data_location='~/.task', create=True): + def __init__(self, data_location='~/.task', create=True, taskrc_location='~/.taskrc'): data_location = os.path.expanduser(data_location) + self.taskrc_location = os.path.expanduser(taskrc_location) + + # If taskrc does not exist, pass / to use defaults and avoid creating + # dummy .taskrc file by TaskWarrior + if not os.path.exists(self.taskrc_location): + self.taskrc_location = '/' + if create and not os.path.exists(data_location): os.makedirs(data_location) + self.config = { - 'data.location': os.path.expanduser(data_location), + 'data.location': data_location, 'confirmation': 'no', 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page 'recurrence.confirmation': 'no', # Necessary for modifying R tasks @@ -761,7 +876,7 @@ class TaskWarrior(object): self.version = self._get_version() def _get_command_args(self, args, config_override={}): - command_args = ['task', 'rc:/'] + command_args = ['task', 'rc:{0}'.format(self.taskrc_location)] config = self.config.copy() config.update(config_override) for item in config.items(): @@ -786,13 +901,22 @@ class TaskWarrior(object): stdout, stderr = [x.decode('utf-8') for x in p.communicate()] if p.returncode and allow_failure: if stderr.strip(): - error_msg = stderr.strip().splitlines()[-1] + error_msg = stderr.strip() else: error_msg = stdout.strip() raise TaskWarriorException(error_msg) return stdout.strip().split('\n') + def enforce_recurrence(self): + # Run arbitrary report command which will trigger generation + # of recurrent tasks. + + # Only necessary for TW up to 2.4.1, fixed in 2.4.2. + if self.version < VERSION_2_4_2: + self.execute_command(['next'], allow_failure=False) + def filter_tasks(self, filter_obj): + self.enforce_recurrence() args = ['export', '--'] + filter_obj.get_filter_params() tasks = [] for line in self.execute_command(args):