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.
  12 from .task import Task, TaskQuerySet
 
  13 from .filters import TaskWarriorFilter
 
  14 from .serializing import local_zone
 
  16 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
 
  18 logger = logging.getLogger(__name__)
 
  21 class Backend(object):
 
  24     def filter_class(self):
 
  25         """Returns the TaskFilter class used by this backend"""
 
  29     def filter_tasks(self, filter_obj):
 
  30         """Returns a list of Task objects matching the given filter"""
 
  34     def save_task(self, task):
 
  38     def delete_task(self, task):
 
  42     def start_task(self, task):
 
  46     def stop_task(self, task):
 
  50     def complete_task(self, task):
 
  54     def refresh_task(self, task, after_save=False):
 
  56         Refreshes the given task. Returns new data dict with serialized
 
  62     def annotate_task(self, task, annotation):
 
  66     def denotate_task(self, task, annotation):
 
  71         """Syncs the backend database with the taskd server"""
 
  74     def convert_datetime_string(self, value):
 
  76         Converts TW syntax datetime string to a localized datetime
 
  77         object. This method is not mandatory.
 
  82 class TaskWarriorException(Exception):
 
  86 class TaskWarrior(Backend):
 
  88     VERSION_2_1_0 = six.u('2.1.0')
 
  89     VERSION_2_2_0 = six.u('2.2.0')
 
  90     VERSION_2_3_0 = six.u('2.3.0')
 
  91     VERSION_2_4_0 = six.u('2.4.0')
 
  92     VERSION_2_4_1 = six.u('2.4.1')
 
  93     VERSION_2_4_2 = six.u('2.4.2')
 
  94     VERSION_2_4_3 = six.u('2.4.3')
 
  95     VERSION_2_4_4 = six.u('2.4.4')
 
  96     VERSION_2_4_5 = six.u('2.4.5')
 
  98     def __init__(self, data_location=None, create=True, taskrc_location='~/.taskrc'):
 
  99         self.taskrc_location = os.path.expanduser(taskrc_location)
 
 101         # If taskrc does not exist, pass / to use defaults and avoid creating
 
 102         # dummy .taskrc file by TaskWarrior
 
 103         if not os.path.exists(self.taskrc_location):
 
 104             self.taskrc_location = '/'
 
 107         self.version = self._get_version()
 
 109             'confirmation': 'no',
 
 110             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
 
 111             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
 
 113             # Defaults to on since 2.4.5, we expect off during parsing
 
 116             # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
 
 117             # arbitrary big number which is likely to be large enough
 
 118             'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
 
 121         # Set data.location override if passed via kwarg
 
 122         if data_location is not None:
 
 123             data_location = os.path.expanduser(data_location)
 
 124             if create and not os.path.exists(data_location):
 
 125                 os.makedirs(data_location)
 
 126             self.config['data.location'] = data_location
 
 128         self.tasks = TaskQuerySet(self)
 
 130     def _get_command_args(self, args, config_override=None):
 
 131         command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
 
 132         config = self.config.copy()
 
 133         config.update(config_override or dict())
 
 134         for item in config.items():
 
 135             command_args.append('rc.{0}={1}'.format(*item))
 
 136         command_args.extend(map(six.text_type, args))
 
 139     def _get_version(self):
 
 140         p = subprocess.Popen(
 
 141             ['task', '--version'],
 
 142             stdout=subprocess.PIPE,
 
 143             stderr=subprocess.PIPE)
 
 144         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 145         return stdout.strip('\n')
 
 147     def _get_modified_task_fields_as_args(self, task):
 
 150         def add_field(field):
 
 151             # Add the output of format_field method to args list (defaults to
 
 153             serialized_value = task._serialize(field, task._data[field])
 
 155             # Empty values should not be enclosed in quotation marks, see
 
 157             if serialized_value is '':
 
 158                 escaped_serialized_value = ''
 
 160                 escaped_serialized_value = six.u("'{0}'").format(
 
 163             format_default = lambda task: six.u("{0}:{1}").format(
 
 164                 field, escaped_serialized_value)
 
 166             format_func = getattr(self, 'format_{0}'.format(field),
 
 169             args.append(format_func(task))
 
 171         # If we're modifying saved task, simply pass on all modified fields
 
 173             for field in task._modified_fields:
 
 175         # For new tasks, pass all fields that make sense
 
 177             for field in task._data.keys():
 
 178                 if field in task.read_only_fields:
 
 184     def format_depends(self, task):
 
 185         # We need to generate added and removed dependencies list,
 
 186         # since Taskwarrior does not accept redefining dependencies.
 
 188         # This cannot be part of serialize_depends, since we need
 
 189         # to keep a list of all depedencies in the _data dictionary,
 
 190         # not just currently added/removed ones
 
 192         old_dependencies = task._original_data.get('depends', set())
 
 194         added = task['depends'] - old_dependencies
 
 195         removed = old_dependencies - task['depends']
 
 197         # Removed dependencies need to be prefixed with '-'
 
 198         return 'depends:' + ','.join(
 
 199             [t['uuid'] for t in added] +
 
 200             ['-' + t['uuid'] for t in removed]
 
 203     def format_description(self, task):
 
 204         # Task version older than 2.4.0 ignores first word of the
 
 205         # task description if description: prefix is used
 
 206         if self.version < self.VERSION_2_4_0:
 
 207             return task._data['description']
 
 209             return six.u("description:'{0}'").format(task._data['description'] or '')
 
 211     def convert_datetime_string(self, value):
 
 213         if self.version >= self.VERSION_2_4_0:
 
 214             # For strings, use 'task calc' to evaluate the string to datetime
 
 215             # available since TW 2.4.0
 
 217             result = self.execute_command(['calc'] + args)
 
 218             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
 
 219             localized = local_zone.localize(naive)
 
 221             raise ValueError("Provided value could not be converted to "
 
 222                              "datetime, its type is not supported: {}"
 
 223                              .format(type(value)))
 
 228     def filter_class(self):
 
 229         return TaskWarriorFilter
 
 235         # First, check if memoized information is available
 
 237             return copy.deepcopy(self._config)
 
 239         # If not, fetch the config using the 'show' command
 
 240         raw_output = self.execute_command(
 
 242             config_override={'verbose': 'nothing'}
 
 246         config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
 
 248         for line in raw_output:
 
 249             match = config_regex.match(line)
 
 251                 config[match.group('key')] = match.group('value').strip()
 
 253         # Memoize the config dict
 
 254         self._config = config
 
 256         return copy.deepcopy(config)
 
 258     def execute_command(self, args, config_override=None, allow_failure=True,
 
 260         command_args = self._get_command_args(
 
 261             args, config_override=config_override)
 
 262         logger.debug(' '.join(command_args))
 
 263         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
 
 264                              stderr=subprocess.PIPE)
 
 265         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 266         if p.returncode and allow_failure:
 
 268                 error_msg = stderr.strip()
 
 270                 error_msg = stdout.strip()
 
 271             raise TaskWarriorException(error_msg)
 
 273         # Return all whole triplet only if explicitly asked for
 
 275             return stdout.rstrip().split('\n')
 
 277             return (stdout.rstrip().split('\n'),
 
 278                     stderr.rstrip().split('\n'),
 
 281     def enforce_recurrence(self):
 
 282         # Run arbitrary report command which will trigger generation
 
 283         # of recurrent tasks.
 
 285         # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
 
 286         if self.version < self.VERSION_2_4_2:
 
 287             self.execute_command(['next'], allow_failure=False)
 
 289     def merge_with(self, path, push=False):
 
 290         path = path.rstrip('/') + '/'
 
 291         self.execute_command(['merge', path], config_override={
 
 292             'merge.autopush': 'yes' if push else 'no',
 
 296         self.execute_command(['undo'])
 
 298     # Backend interface implementation
 
 300     def filter_tasks(self, filter_obj):
 
 301         self.enforce_recurrence()
 
 302         args = ['export', '--'] + filter_obj.get_filter_params()
 
 304         for line in self.execute_command(args):
 
 306                 data = line.strip(',')
 
 308                     filtered_task = Task(self)
 
 309                     filtered_task._load_data(json.loads(data))
 
 310                     tasks.append(filtered_task)
 
 312                     raise TaskWarriorException('Invalid JSON: %s' % data)
 
 315     def save_task(self, task):
 
 316         """Save a task into TaskWarrior database using add/modify call"""
 
 318         args = [task['uuid'], 'modify'] if task.saved else ['add']
 
 319         args.extend(self._get_modified_task_fields_as_args(task))
 
 320         output = self.execute_command(args)
 
 322         # Parse out the new ID, if the task is being added for the first time
 
 324             id_lines = [l for l in output if l.startswith('Created task ')]
 
 326             # Complain loudly if it seems that more tasks were created
 
 328             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
 
 329                 raise TaskWarriorException("Unexpected output when creating "
 
 330                                            "task: %s" % '\n'.join(id_lines))
 
 332             # Circumvent the ID storage, since ID is considered read-only
 
 333             identifier = id_lines[0].split(' ')[2].rstrip('.')
 
 335             # Identifier can be either ID or UUID for completed tasks
 
 337                 task._data['id'] = int(identifier)
 
 339                 task._data['uuid'] = identifier
 
 341         # Refreshing is very important here, as not only modification time
 
 342         # is updated, but arbitrary attribute may have changed due hooks
 
 343         # altering the data before saving
 
 344         task.refresh(after_save=True)
 
 346     def delete_task(self, task):
 
 347         self.execute_command([task['uuid'], 'delete'])
 
 349     def start_task(self, task):
 
 350         self.execute_command([task['uuid'], 'start'])
 
 352     def stop_task(self, task):
 
 353         self.execute_command([task['uuid'], 'stop'])
 
 355     def complete_task(self, task):
 
 356         # Older versions of TW do not stop active task at completion
 
 357         if self.version < self.VERSION_2_4_0 and task.active:
 
 360         self.execute_command([task['uuid'], 'done'])
 
 362     def annotate_task(self, task, annotation):
 
 363         args = [task['uuid'], 'annotate', annotation]
 
 364         self.execute_command(args)
 
 366     def denotate_task(self, task, annotation):
 
 367         args = [task['uuid'], 'denotate', annotation]
 
 368         self.execute_command(args)
 
 370     def refresh_task(self, task, after_save=False):
 
 371         # We need to use ID as backup for uuid here for the refreshes
 
 372         # of newly saved tasks. Any other place in the code is fine
 
 373         # with using UUID only.
 
 374         args = [task['uuid'] or task['id'], 'export']
 
 375         output = self.execute_command(args)
 
 378             return len(output) == 1 and output[0].startswith('{')
 
 380         # For older TW versions attempt to uniquely locate the task
 
 381         # using the data we have if it has been just saved.
 
 382         # This can happen when adding a completed task on older TW versions.
 
 383         if (not valid(output) and self.version < self.VERSION_2_4_5
 
 386             # Make a copy, removing ID and UUID. It's most likely invalid
 
 387             # (ID 0) if it failed to match a unique task.
 
 388             data = copy.deepcopy(task._data)
 
 390             data.pop('uuid', None)
 
 392             taskfilter = self.filter_class(self)
 
 393             for key, value in data.items():
 
 394                 taskfilter.add_filter_param(key, value)
 
 396             output = self.execute_command(['export', '--'] +
 
 397                                           taskfilter.get_filter_params())
 
 399         # If more than 1 task has been matched still, raise an exception
 
 400         if not valid(output):
 
 401             raise TaskWarriorException(
 
 402                 "Unique identifiers {0} with description: {1} matches "
 
 403                 "multiple tasks: {2}".format(
 
 404                     task['uuid'] or task['id'], task['description'], output)
 
 407         return json.loads(output[0])
 
 410         self.execute_command(['sync'])