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, ReadOnlyDictView
 
  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__)
 
  20 class Backend(object):
 
  23     def filter_class(self):
 
  24         """Returns the TaskFilter class used by this backend"""
 
  28     def filter_tasks(self, filter_obj):
 
  29         """Returns a list of Task objects matching the given filter"""
 
  33     def save_task(self, task):
 
  37     def delete_task(self, task):
 
  41     def start_task(self, task):
 
  45     def stop_task(self, task):
 
  49     def complete_task(self, task):
 
  53     def refresh_task(self, task, after_save=False):
 
  55         Refreshes the given task. Returns new data dict with serialized
 
  61     def annotate_task(self, task, annotation):
 
  65     def denotate_task(self, task, annotation):
 
  70         """Syncs the backend database with the taskd server"""
 
  73     def convert_datetime_string(self, value):
 
  75         Converts TW syntax datetime string to a localized datetime
 
  76         object. This method is not mandatory.
 
  78         raise NotImplementedError
 
  81 class TaskWarriorException(Exception):
 
  85 class TaskWarrior(Backend):
 
  87     VERSION_2_1_0 = six.u('2.1.0')
 
  88     VERSION_2_2_0 = six.u('2.2.0')
 
  89     VERSION_2_3_0 = six.u('2.3.0')
 
  90     VERSION_2_4_0 = six.u('2.4.0')
 
  91     VERSION_2_4_1 = six.u('2.4.1')
 
  92     VERSION_2_4_2 = six.u('2.4.2')
 
  93     VERSION_2_4_3 = six.u('2.4.3')
 
  94     VERSION_2_4_4 = six.u('2.4.4')
 
  95     VERSION_2_4_5 = six.u('2.4.5')
 
  97     def __init__(self, data_location=None, create=True,
 
  98                  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.overrides['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         overrides = self.overrides.copy()
 
 133         overrides.update(config_override or dict())
 
 134         for item in overrides.items():
 
 135             command_args.append('rc.{0}={1}'.format(*item))
 
 136         command_args.extend([
 
 137             x.decode('utf-8') if isinstance(x, six.binary_type)
 
 138             else six.text_type(x) for x in args
 
 142     def _get_version(self):
 
 143         p = subprocess.Popen(
 
 144             ['task', '--version'],
 
 145             stdout=subprocess.PIPE,
 
 146             stderr=subprocess.PIPE)
 
 147         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 148         return stdout.strip('\n')
 
 150     def _get_modified_task_fields_as_args(self, task):
 
 153         def add_field(field):
 
 154             # Add the output of format_field method to args list (defaults to
 
 156             serialized_value = task._serialize(field, task._data[field])
 
 158             # Empty values should not be enclosed in quotation marks, see
 
 160             if serialized_value is '':
 
 161                 escaped_serialized_value = ''
 
 163                 escaped_serialized_value = six.u("'{0}'").format(
 
 166             format_default = lambda task: six.u("{0}:{1}").format(
 
 167                 field, escaped_serialized_value)
 
 169             format_func = getattr(self, 'format_{0}'.format(field),
 
 172             args.append(format_func(task))
 
 174         # If we're modifying saved task, simply pass on all modified fields
 
 176             for field in task._modified_fields:
 
 179         # For new tasks, pass all fields that make sense
 
 181             for field in task._data.keys():
 
 182                 # We cannot set stuff that's read only (ID, UUID, ..)
 
 183                 if field in task.read_only_fields:
 
 185                 # We do not want to do field deletion for new tasks
 
 186                 if task._data[field] is None:
 
 188                 # Otherwise we're fine
 
 193     def format_depends(self, task):
 
 194         # We need to generate added and removed dependencies list,
 
 195         # since Taskwarrior does not accept redefining dependencies.
 
 197         # This cannot be part of serialize_depends, since we need
 
 198         # to keep a list of all depedencies in the _data dictionary,
 
 199         # not just currently added/removed ones
 
 201         old_dependencies = task._original_data.get('depends', set())
 
 203         added = task['depends'] - old_dependencies
 
 204         removed = old_dependencies - task['depends']
 
 206         # Removed dependencies need to be prefixed with '-'
 
 207         return 'depends:' + ','.join(
 
 208             [t['uuid'] for t in added] +
 
 209             ['-' + t['uuid'] for t in removed]
 
 212     def format_description(self, task):
 
 213         # Task version older than 2.4.0 ignores first word of the
 
 214         # task description if description: prefix is used
 
 215         if self.version < self.VERSION_2_4_0:
 
 216             return task._data['description']
 
 218             return six.u("description:'{0}'").format(
 
 219                 task._data['description'] or '',
 
 222     def convert_datetime_string(self, value):
 
 224         if self.version >= self.VERSION_2_4_0:
 
 225             # For strings, use 'task calc' to evaluate the string to datetime
 
 226             # available since TW 2.4.0
 
 228             result = self.execute_command(['calc'] + args)
 
 229             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
 
 230             localized = local_zone.localize(naive)
 
 233                 'Provided value could not be converted to '
 
 234                 'datetime, its type is not supported: {}'
 
 235                 .format(type(value)),
 
 241     def filter_class(self):
 
 242         return TaskWarriorFilter
 
 248         # First, check if memoized information is available
 
 252         # If not, fetch the config using the 'show' command
 
 253         raw_output = self.execute_command(
 
 255             config_override={'verbose': 'nothing'}
 
 259         config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].*$)')
 
 261         for line in raw_output:
 
 262             match = config_regex.match(line)
 
 264                 config[match.group('key')] = match.group('value').strip()
 
 266         # Memoize the config dict
 
 267         self._config = ReadOnlyDictView(config)
 
 271     def execute_command(self, args, config_override=None, allow_failure=True,
 
 273         command_args = self._get_command_args(
 
 274             args, config_override=config_override)
 
 275         logger.debug(u' '.join(command_args))
 
 277         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
 
 278                              stderr=subprocess.PIPE)
 
 279         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 280         if p.returncode and allow_failure:
 
 282                 error_msg = stderr.strip()
 
 284                 error_msg = stdout.strip()
 
 285             error_msg += u'\nCommand used: ' + u' '.join(command_args)
 
 286             raise TaskWarriorException(error_msg)
 
 288         # Return all whole triplet only if explicitly asked for
 
 290             return stdout.rstrip().split('\n')
 
 292             return (stdout.rstrip().split('\n'),
 
 293                     stderr.rstrip().split('\n'),
 
 296     def enforce_recurrence(self):
 
 297         # Run arbitrary report command which will trigger generation
 
 298         # of recurrent tasks.
 
 300         # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
 
 301         if self.version < self.VERSION_2_4_2:
 
 302             self.execute_command(['next'], allow_failure=False)
 
 304     def merge_with(self, path, push=False):
 
 305         path = path.rstrip('/') + '/'
 
 306         self.execute_command(['merge', path], config_override={
 
 307             'merge.autopush': 'yes' if push else 'no',
 
 311         self.execute_command(['undo'])
 
 313     # Backend interface implementation
 
 315     def filter_tasks(self, filter_obj):
 
 316         self.enforce_recurrence()
 
 317         args = ['export'] + filter_obj.get_filter_params()
 
 319         for line in self.execute_command(args):
 
 321                 data = line.strip(',')
 
 323                     filtered_task = Task(self)
 
 324                     filtered_task._load_data(json.loads(data))
 
 325                     tasks.append(filtered_task)
 
 327                     raise TaskWarriorException('Invalid JSON: %s' % data)
 
 330     def save_task(self, task):
 
 331         """Save a task into TaskWarrior database using add/modify call"""
 
 333         args = [task['uuid'], 'modify'] if task.saved else ['add']
 
 334         args.extend(self._get_modified_task_fields_as_args(task))
 
 335         output = self.execute_command(args)
 
 337         # Parse out the new ID, if the task is being added for the first time
 
 339             id_lines = [l for l in output if l.startswith('Created task ')]
 
 341             # Complain loudly if it seems that more tasks were created
 
 343             # Expected output: Created task 1.
 
 344             #                  Created task 1 (recurrence template).
 
 345             if len(id_lines) != 1 or len(id_lines[0].split(' ')) not in (3, 5):
 
 346                 raise TaskWarriorException(
 
 347                     'Unexpected output when creating '
 
 348                     'task: %s' % '\n'.join(id_lines),
 
 351             # Circumvent the ID storage, since ID is considered read-only
 
 352             identifier = id_lines[0].split(' ')[2].rstrip('.')
 
 354             # Identifier can be either ID or UUID for completed tasks
 
 356                 task._data['id'] = int(identifier)
 
 358                 task._data['uuid'] = identifier
 
 360         # Refreshing is very important here, as not only modification time
 
 361         # is updated, but arbitrary attribute may have changed due hooks
 
 362         # altering the data before saving
 
 363         task.refresh(after_save=True)
 
 365     def delete_task(self, task):
 
 366         self.execute_command([task['uuid'], 'delete'])
 
 368     def start_task(self, task):
 
 369         self.execute_command([task['uuid'], 'start'])
 
 371     def stop_task(self, task):
 
 372         self.execute_command([task['uuid'], 'stop'])
 
 374     def complete_task(self, task):
 
 375         # Older versions of TW do not stop active task at completion
 
 376         if self.version < self.VERSION_2_4_0 and task.active:
 
 379         self.execute_command([task['uuid'], 'done'])
 
 381     def annotate_task(self, task, annotation):
 
 382         args = [task['uuid'], 'annotate', annotation]
 
 383         self.execute_command(args)
 
 385     def denotate_task(self, task, annotation):
 
 386         args = [task['uuid'], 'denotate', annotation]
 
 387         self.execute_command(args)
 
 389     def refresh_task(self, task, after_save=False):
 
 390         # We need to use ID as backup for uuid here for the refreshes
 
 391         # of newly saved tasks. Any other place in the code is fine
 
 392         # with using UUID only.
 
 393         args = [task['uuid'] or task['id'], 'export']
 
 394         output = self.execute_command(args)
 
 397             return len(output) == 1 and output[0].startswith('{')
 
 399         # For older TW versions attempt to uniquely locate the task
 
 400         # using the data we have if it has been just saved.
 
 401         # This can happen when adding a completed task on older TW versions.
 
 402         if (not valid(output) and self.version < self.VERSION_2_4_5
 
 405             # Make a copy, removing ID and UUID. It's most likely invalid
 
 406             # (ID 0) if it failed to match a unique task.
 
 407             data = copy.deepcopy(task._data)
 
 409             data.pop('uuid', None)
 
 411             taskfilter = self.filter_class(self)
 
 412             for key, value in data.items():
 
 413                 taskfilter.add_filter_param(key, value)
 
 415             output = self.execute_command(['export'] +
 
 416                                           taskfilter.get_filter_params())
 
 418         # If more than 1 task has been matched still, raise an exception
 
 419         if not valid(output):
 
 420             raise TaskWarriorException(
 
 421                 'Unique identifiers {0} with description: {1} matches '
 
 422                 'multiple tasks: {2}'.format(
 
 423                     task['uuid'] or task['id'], task['description'], output)
 
 426         return json.loads(output[0])
 
 429         self.execute_command(['sync'])