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.
  11 from .task import Task, TaskQuerySet
 
  12 from .filters import TaskWarriorFilter
 
  13 from .serializing import local_zone
 
  15 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
 
  17 logger = logging.getLogger(__name__)
 
  19 class Backend(object):
 
  22     def filter_class(self):
 
  23         """Returns the TaskFilter class used by this backend"""
 
  27     def filter_tasks(self, filter_obj):
 
  28         """Returns a list of Task objects matching the given filter"""
 
  32     def save_task(self, task):
 
  36     def delete_task(self, task):
 
  40     def start_task(self, task):
 
  44     def stop_task(self, task):
 
  48     def complete_task(self, task):
 
  52     def refresh_task(self, task, after_save=False):
 
  54         Refreshes the given task. Returns new data dict with serialized
 
  60     def annotate_task(self, task, annotation):
 
  64     def denotate_task(self, task, annotation):
 
  69         """Syncs the backend database with the taskd server"""
 
  72     def convert_datetime_string(self, value):
 
  74         Converts TW syntax datetime string to a localized datetime
 
  75         object. This method is not mandatory.
 
  80 class TaskWarriorException(Exception):
 
  84 class TaskWarrior(Backend):
 
  86     VERSION_2_1_0 = six.u('2.1.0')
 
  87     VERSION_2_2_0 = six.u('2.2.0')
 
  88     VERSION_2_3_0 = six.u('2.3.0')
 
  89     VERSION_2_4_0 = six.u('2.4.0')
 
  90     VERSION_2_4_1 = six.u('2.4.1')
 
  91     VERSION_2_4_2 = six.u('2.4.2')
 
  92     VERSION_2_4_3 = six.u('2.4.3')
 
  93     VERSION_2_4_4 = six.u('2.4.4')
 
  94     VERSION_2_4_5 = six.u('2.4.5')
 
  96     def __init__(self, data_location=None, create=True, taskrc_location='~/.taskrc'):
 
  97         self.taskrc_location = os.path.expanduser(taskrc_location)
 
  99         # If taskrc does not exist, pass / to use defaults and avoid creating
 
 100         # dummy .taskrc file by TaskWarrior
 
 101         if not os.path.exists(self.taskrc_location):
 
 102             self.taskrc_location = '/'
 
 104         self.version = self._get_version()
 
 106             'confirmation': 'no',
 
 107             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
 
 108             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
 
 110             # Defaults to on since 2.4.5, we expect off during parsing
 
 113             # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
 
 114             # arbitrary big number which is likely to be large enough
 
 115             'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
 
 118         # Set data.location override if passed via kwarg
 
 119         if data_location is not None:
 
 120             data_location = os.path.expanduser(data_location)
 
 121             if create and not os.path.exists(data_location):
 
 122                 os.makedirs(data_location)
 
 123             self.config['data.location'] = data_location
 
 125         self.tasks = TaskQuerySet(self)
 
 127     def _get_command_args(self, args, config_override=None):
 
 128         command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
 
 129         config = self.config.copy()
 
 130         config.update(config_override or dict())
 
 131         for item in config.items():
 
 132             command_args.append('rc.{0}={1}'.format(*item))
 
 133         command_args.extend(map(six.text_type, args))
 
 136     def _get_version(self):
 
 137         p = subprocess.Popen(
 
 138                 ['task', '--version'],
 
 139                 stdout=subprocess.PIPE,
 
 140                 stderr=subprocess.PIPE)
 
 141         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 142         return stdout.strip('\n')
 
 144     def _get_modified_task_fields_as_args(self, task):
 
 147         def add_field(field):
 
 148             # Add the output of format_field method to args list (defaults to
 
 150             serialized_value = task._serialize(field, task._data[field])
 
 152             # Empty values should not be enclosed in quotation marks, see
 
 154             if serialized_value is '':
 
 155                 escaped_serialized_value = ''
 
 157                 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
 
 159             format_default = lambda task: six.u("{0}:{1}").format(field,
 
 160                                                       escaped_serialized_value)
 
 162             format_func = getattr(self, 'format_{0}'.format(field),
 
 165             args.append(format_func(task))
 
 167         # If we're modifying saved task, simply pass on all modified fields
 
 169             for field in task._modified_fields:
 
 171         # For new tasks, pass all fields that make sense
 
 173             for field in task._data.keys():
 
 174                 if field in task.read_only_fields:
 
 180     def format_depends(self, task):
 
 181         # We need to generate added and removed dependencies list,
 
 182         # since Taskwarrior does not accept redefining dependencies.
 
 184         # This cannot be part of serialize_depends, since we need
 
 185         # to keep a list of all depedencies in the _data dictionary,
 
 186         # not just currently added/removed ones
 
 188         old_dependencies = task._original_data.get('depends', set())
 
 190         added = task['depends'] - old_dependencies
 
 191         removed = old_dependencies - task['depends']
 
 193         # Removed dependencies need to be prefixed with '-'
 
 194         return 'depends:' + ','.join(
 
 195                 [t['uuid'] for t in added] +
 
 196                 ['-' + t['uuid'] for t in removed]
 
 199     def format_description(self, task):
 
 200         # Task version older than 2.4.0 ignores first word of the
 
 201         # task description if description: prefix is used
 
 202         if self.version < self.VERSION_2_4_0:
 
 203             return task._data['description']
 
 205             return six.u("description:'{0}'").format(task._data['description'] or '')
 
 207     def convert_datetime_string(self, value):
 
 209         if self.version >= self.VERSION_2_4_0:
 
 210             # For strings, use 'task calc' to evaluate the string to datetime
 
 211             # available since TW 2.4.0
 
 213             result = self.execute_command(['calc'] + args)
 
 214             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
 
 215             localized = local_zone.localize(naive)
 
 217             raise ValueError("Provided value could not be converted to "
 
 218                              "datetime, its type is not supported: {}"
 
 219                              .format(type(value)))
 
 224     def filter_class(self):
 
 225         return TaskWarriorFilter
 
 229     def get_config(self):
 
 230         raw_output = self.execute_command(
 
 232                 config_override={'verbose': 'nothing'}
 
 236         config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
 
 238         for line in raw_output:
 
 239             match = config_regex.match(line)
 
 241                 config[match.group('key')] = match.group('value').strip()
 
 245     def execute_command(self, args, config_override=None, allow_failure=True,
 
 247         command_args = self._get_command_args(
 
 248             args, config_override=config_override)
 
 249         logger.debug(' '.join(command_args))
 
 250         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
 
 251                              stderr=subprocess.PIPE)
 
 252         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 253         if p.returncode and allow_failure:
 
 255                 error_msg = stderr.strip()
 
 257                 error_msg = stdout.strip()
 
 258             raise TaskWarriorException(error_msg)
 
 260         # Return all whole triplet only if explicitly asked for
 
 262             return stdout.rstrip().split('\n')
 
 264             return (stdout.rstrip().split('\n'),
 
 265                     stderr.rstrip().split('\n'),
 
 268     def enforce_recurrence(self):
 
 269         # Run arbitrary report command which will trigger generation
 
 270         # of recurrent tasks.
 
 272         # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
 
 273         if self.version < self.VERSION_2_4_2:
 
 274             self.execute_command(['next'], allow_failure=False)
 
 276     def merge_with(self, path, push=False):
 
 277         path = path.rstrip('/') + '/'
 
 278         self.execute_command(['merge', path], config_override={
 
 279             'merge.autopush': 'yes' if push else 'no',
 
 283         self.execute_command(['undo'])
 
 285     # Backend interface implementation
 
 287     def filter_tasks(self, filter_obj):
 
 288         self.enforce_recurrence()
 
 289         args = ['export', '--'] + filter_obj.get_filter_params()
 
 291         for line in self.execute_command(args):
 
 293                 data = line.strip(',')
 
 295                     filtered_task = Task(self)
 
 296                     filtered_task._load_data(json.loads(data))
 
 297                     tasks.append(filtered_task)
 
 299                     raise TaskWarriorException('Invalid JSON: %s' % data)
 
 302     def save_task(self, task):
 
 303         """Save a task into TaskWarrior database using add/modify call"""
 
 305         args = [task['uuid'], 'modify'] if task.saved else ['add']
 
 306         args.extend(self._get_modified_task_fields_as_args(task))
 
 307         output = self.execute_command(args)
 
 309         # Parse out the new ID, if the task is being added for the first time
 
 311             id_lines = [l for l in output if l.startswith('Created task ')]
 
 313             # Complain loudly if it seems that more tasks were created
 
 315             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
 
 316                 raise TaskWarriorException("Unexpected output when creating "
 
 317                                            "task: %s" % '\n'.join(id_lines))
 
 319             # Circumvent the ID storage, since ID is considered read-only
 
 320             identifier = id_lines[0].split(' ')[2].rstrip('.')
 
 322             # Identifier can be either ID or UUID for completed tasks
 
 324                 task._data['id'] = int(identifier)
 
 326                 task._data['uuid'] = identifier
 
 328         # Refreshing is very important here, as not only modification time
 
 329         # is updated, but arbitrary attribute may have changed due hooks
 
 330         # altering the data before saving
 
 331         task.refresh(after_save=True)
 
 333     def delete_task(self, task):
 
 334         self.execute_command([task['uuid'], 'delete'])
 
 336     def start_task(self, task):
 
 337         self.execute_command([task['uuid'], 'start'])
 
 339     def stop_task(self, task):
 
 340         self.execute_command([task['uuid'], 'stop'])
 
 342     def complete_task(self, task):
 
 343         # Older versions of TW do not stop active task at completion
 
 344         if self.version < self.VERSION_2_4_0 and task.active:
 
 347         self.execute_command([task['uuid'], 'done'])
 
 349     def annotate_task(self, task, annotation):
 
 350         args = [task['uuid'], 'annotate', annotation]
 
 351         self.execute_command(args)
 
 353     def denotate_task(self, task, annotation):
 
 354         args = [task['uuid'], 'denotate', annotation]
 
 355         self.execute_command(args)
 
 357     def refresh_task(self, task, after_save=False):
 
 358         # We need to use ID as backup for uuid here for the refreshes
 
 359         # of newly saved tasks. Any other place in the code is fine
 
 360         # with using UUID only.
 
 361         args = [task['uuid'] or task['id'], 'export']
 
 362         output = self.execute_command(args)
 
 365             return len(output) == 1 and output[0].startswith('{')
 
 367         # For older TW versions attempt to uniquely locate the task
 
 368         # using the data we have if it has been just saved.
 
 369         # This can happen when adding a completed task on older TW versions.
 
 370         if (not valid(output) and self.version < self.VERSION_2_4_5
 
 373             # Make a copy, removing ID and UUID. It's most likely invalid
 
 374             # (ID 0) if it failed to match a unique task.
 
 375             data = copy.deepcopy(task._data)
 
 377             data.pop('uuid', None)
 
 379             taskfilter = self.filter_class(self)
 
 380             for key, value in data.items():
 
 381                 taskfilter.add_filter_param(key, value)
 
 383             output = self.execute_command(['export', '--'] +
 
 384                 taskfilter.get_filter_params())
 
 386         # If more than 1 task has been matched still, raise an exception
 
 387         if not valid(output):
 
 388             raise TaskWarriorException(
 
 389                 "Unique identifiers {0} with description: {1} matches "
 
 390                 "multiple tasks: {2}".format(
 
 391                 task['uuid'] or task['id'], task['description'], output)
 
 394         return json.loads(output[0])
 
 397         self.execute_command(['sync'])