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.
  10 from .task import Task, TaskQuerySet
 
  11 from .filters import TaskWarriorFilter
 
  12 from .serializing import local_zone
 
  14 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
 
  16 logger = logging.getLogger(__name__)
 
  18 class Backend(object):
 
  21     def filter_class(self):
 
  22         """Returns the TaskFilter class used by this backend"""
 
  26     def filter_tasks(self, filter_obj):
 
  27         """Returns a list of Task objects matching the given filter"""
 
  31     def save_task(self, task):
 
  35     def delete_task(self, task):
 
  39     def start_task(self, task):
 
  43     def stop_task(self, task):
 
  47     def complete_task(self, task):
 
  51     def refresh_task(self, task, after_save=False):
 
  53         Refreshes the given task. Returns new data dict with serialized
 
  59     def annotate_task(self, task, annotation):
 
  63     def denotate_task(self, task, annotation):
 
  68         """Syncs the backend database with the taskd server"""
 
  71     def convert_datetime_string(self, value):
 
  73         Converts TW syntax datetime string to a localized datetime
 
  74         object. This method is not mandatory.
 
  79 class TaskWarriorException(Exception):
 
  83 class TaskWarrior(object):
 
  85     VERSION_2_1_0 = six.u('2.1.0')
 
  86     VERSION_2_2_0 = six.u('2.2.0')
 
  87     VERSION_2_3_0 = six.u('2.3.0')
 
  88     VERSION_2_4_0 = six.u('2.4.0')
 
  89     VERSION_2_4_1 = six.u('2.4.1')
 
  90     VERSION_2_4_2 = six.u('2.4.2')
 
  91     VERSION_2_4_3 = six.u('2.4.3')
 
  92     VERSION_2_4_4 = six.u('2.4.4')
 
  93     VERSION_2_4_5 = six.u('2.4.5')
 
  95     def __init__(self, data_location=None, create=True, taskrc_location='~/.taskrc'):
 
  96         self.taskrc_location = os.path.expanduser(taskrc_location)
 
  98         # If taskrc does not exist, pass / to use defaults and avoid creating
 
  99         # dummy .taskrc file by TaskWarrior
 
 100         if not os.path.exists(self.taskrc_location):
 
 101             self.taskrc_location = '/'
 
 103         self.version = self._get_version()
 
 105             'confirmation': 'no',
 
 106             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
 
 107             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
 
 109             # Defaults to on since 2.4.5, we expect off during parsing
 
 112             # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
 
 113             # arbitrary big number which is likely to be large enough
 
 114             'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
 
 117         # Set data.location override if passed via kwarg
 
 118         if data_location is not None:
 
 119             data_location = os.path.expanduser(data_location)
 
 120             if create and not os.path.exists(data_location):
 
 121                 os.makedirs(data_location)
 
 122             self.config['data.location'] = data_location
 
 124         self.tasks = TaskQuerySet(self)
 
 126     def _get_command_args(self, args, config_override=None):
 
 127         command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
 
 128         config = self.config.copy()
 
 129         config.update(config_override or dict())
 
 130         for item in config.items():
 
 131             command_args.append('rc.{0}={1}'.format(*item))
 
 132         command_args.extend(map(six.text_type, args))
 
 135     def _get_version(self):
 
 136         p = subprocess.Popen(
 
 137                 ['task', '--version'],
 
 138                 stdout=subprocess.PIPE,
 
 139                 stderr=subprocess.PIPE)
 
 140         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 141         return stdout.strip('\n')
 
 143     def _get_modified_task_fields_as_args(self, task):
 
 146         def add_field(field):
 
 147             # Add the output of format_field method to args list (defaults to
 
 149             serialized_value = task._serialize(field, task._data[field])
 
 151             # Empty values should not be enclosed in quotation marks, see
 
 153             if serialized_value is '':
 
 154                 escaped_serialized_value = ''
 
 156                 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
 
 158             format_default = lambda task: six.u("{0}:{1}").format(field,
 
 159                                                       escaped_serialized_value)
 
 161             format_func = getattr(self, 'format_{0}'.format(field),
 
 164             args.append(format_func(task))
 
 166         # If we're modifying saved task, simply pass on all modified fields
 
 168             for field in task._modified_fields:
 
 170         # For new tasks, pass all fields that make sense
 
 172             for field in task._data.keys():
 
 173                 if field in task.read_only_fields:
 
 179     def format_depends(self, task):
 
 180         # We need to generate added and removed dependencies list,
 
 181         # since Taskwarrior does not accept redefining dependencies.
 
 183         # This cannot be part of serialize_depends, since we need
 
 184         # to keep a list of all depedencies in the _data dictionary,
 
 185         # not just currently added/removed ones
 
 187         old_dependencies = task._original_data.get('depends', set())
 
 189         added = task['depends'] - old_dependencies
 
 190         removed = old_dependencies - task['depends']
 
 192         # Removed dependencies need to be prefixed with '-'
 
 193         return 'depends:' + ','.join(
 
 194                 [t['uuid'] for t in added] +
 
 195                 ['-' + t['uuid'] for t in removed]
 
 198     def format_description(self, task):
 
 199         # Task version older than 2.4.0 ignores first word of the
 
 200         # task description if description: prefix is used
 
 201         if self.version < self.VERSION_2_4_0:
 
 202             return task._data['description']
 
 204             return six.u("description:'{0}'").format(task._data['description'] or '')
 
 206     def convert_datetime_string(self, value):
 
 208         if self.version >= self.VERSION_2_4_0:
 
 209             # For strings, use 'task calc' to evaluate the string to datetime
 
 210             # available since TW 2.4.0
 
 212             result = self.execute_command(['calc'] + args)
 
 213             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
 
 214             localized = local_zone.localize(naive)
 
 216             raise ValueError("Provided value could not be converted to "
 
 217                              "datetime, its type is not supported: {}"
 
 218                              .format(type(value)))
 
 221     def filter_class(self):
 
 222         return TaskWarriorFilter
 
 226     def get_config(self):
 
 227         raw_output = self.execute_command(
 
 229                 config_override={'verbose': 'nothing'}
 
 233         config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
 
 235         for line in raw_output:
 
 236             match = config_regex.match(line)
 
 238                 config[match.group('key')] = match.group('value').strip()
 
 242     def execute_command(self, args, config_override=None, allow_failure=True,
 
 244         command_args = self._get_command_args(
 
 245             args, config_override=config_override)
 
 246         logger.debug(' '.join(command_args))
 
 247         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
 
 248                              stderr=subprocess.PIPE)
 
 249         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
 
 250         if p.returncode and allow_failure:
 
 252                 error_msg = stderr.strip()
 
 254                 error_msg = stdout.strip()
 
 255             raise TaskWarriorException(error_msg)
 
 257         # Return all whole triplet only if explicitly asked for
 
 259             return stdout.rstrip().split('\n')
 
 261             return (stdout.rstrip().split('\n'),
 
 262                     stderr.rstrip().split('\n'),
 
 265     def enforce_recurrence(self):
 
 266         # Run arbitrary report command which will trigger generation
 
 267         # of recurrent tasks.
 
 269         # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
 
 270         if self.version < self.VERSION_2_4_2:
 
 271             self.execute_command(['next'], allow_failure=False)
 
 273     def merge_with(self, path, push=False):
 
 274         path = path.rstrip('/') + '/'
 
 275         self.execute_command(['merge', path], config_override={
 
 276             'merge.autopush': 'yes' if push else 'no',
 
 280         self.execute_command(['undo'])
 
 282     # Backend interface implementation
 
 284     def filter_tasks(self, filter_obj):
 
 285         self.enforce_recurrence()
 
 286         args = ['export', '--'] + filter_obj.get_filter_params()
 
 288         for line in self.execute_command(args):
 
 290                 data = line.strip(',')
 
 292                     filtered_task = Task(self)
 
 293                     filtered_task._load_data(json.loads(data))
 
 294                     tasks.append(filtered_task)
 
 296                     raise TaskWarriorException('Invalid JSON: %s' % data)
 
 299     def save_task(self, task):
 
 300         """Save a task into TaskWarrior database using add/modify call"""
 
 302         args = [task['uuid'], 'modify'] if task.saved else ['add']
 
 303         args.extend(self._get_modified_task_fields_as_args(task))
 
 304         output = self.execute_command(args)
 
 306         # Parse out the new ID, if the task is being added for the first time
 
 308             id_lines = [l for l in output if l.startswith('Created task ')]
 
 310             # Complain loudly if it seems that more tasks were created
 
 312             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
 
 313                 raise TaskWarriorException("Unexpected output when creating "
 
 314                                            "task: %s" % '\n'.join(id_lines))
 
 316             # Circumvent the ID storage, since ID is considered read-only
 
 317             identifier = id_lines[0].split(' ')[2].rstrip('.')
 
 319             # Identifier can be either ID or UUID for completed tasks
 
 321                 task._data['id'] = int(identifier)
 
 323                 task._data['uuid'] = identifier
 
 325         # Refreshing is very important here, as not only modification time
 
 326         # is updated, but arbitrary attribute may have changed due hooks
 
 327         # altering the data before saving
 
 328         task.refresh(after_save=True)
 
 330     def delete_task(self, task):
 
 331         self.execute_command([task['uuid'], 'delete'])
 
 333     def start_task(self, task):
 
 334         self.execute_command([task['uuid'], 'start'])
 
 336     def stop_task(self, task):
 
 337         self.execute_command([task['uuid'], 'stop'])
 
 339     def complete_task(self, task):
 
 340         # Older versions of TW do not stop active task at completion
 
 341         if self.version < self.VERSION_2_4_0 and task.active:
 
 344         self.execute_command([task['uuid'], 'done'])
 
 346     def annotate_task(self, task, annotation):
 
 347         args = [task['uuid'], 'annotate', annotation]
 
 348         self.execute_command(args)
 
 350     def denotate_task(self, task, annotation):
 
 351         args = [task['uuid'], 'denotate', annotation]
 
 352         self.execute_command(args)
 
 354     def refresh_task(self, task, after_save=False):
 
 355         # We need to use ID as backup for uuid here for the refreshes
 
 356         # of newly saved tasks. Any other place in the code is fine
 
 357         # with using UUID only.
 
 358         args = [task['uuid'] or task['id'], 'export']
 
 359         output = self.execute_command(args)
 
 362             return len(output) == 1 and output[0].startswith('{')
 
 364         # For older TW versions attempt to uniquely locate the task
 
 365         # using the data we have if it has been just saved.
 
 366         # This can happen when adding a completed task on older TW versions.
 
 367         if (not valid(output) and self.version < self.VERSION_2_4_5
 
 370             # Make a copy, removing ID and UUID. It's most likely invalid
 
 371             # (ID 0) if it failed to match a unique task.
 
 372             data = copy.deepcopy(task._data)
 
 374             data.pop('uuid', None)
 
 376             taskfilter = self.filter_class(self)
 
 377             for key, value in data.items():
 
 378                 taskfilter.add_filter_param(key, value)
 
 380             output = self.execute_command(['export', '--'] +
 
 381                 taskfilter.get_filter_params())
 
 383         # If more than 1 task has been matched still, raise an exception
 
 384         if not valid(output):
 
 385             raise TaskWarriorException(
 
 386                 "Unique identifiers {0} with description: {1} matches "
 
 387                 "multiple tasks: {2}".format(
 
 388                 task['uuid'] or task['id'], task['description'], output)
 
 391         return json.loads(output[0])
 
 394         self.execute_command(['sync'])