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', task_command='task'):
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 = '/'
106 self.task_command = task_command
109 self.version = self._get_version()
111 'confirmation': 'no',
112 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
113 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
115 # Defaults to on since 2.4.5, we expect off during parsing
118 # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
119 # arbitrary big number which is likely to be large enough
120 'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
123 # Set data.location override if passed via kwarg
124 if data_location is not None:
125 data_location = os.path.expanduser(data_location)
126 if create and not os.path.exists(data_location):
127 os.makedirs(data_location)
128 self.overrides['data.location'] = data_location
130 self.tasks = TaskQuerySet(self)
132 def _get_task_command(self):
133 return self.task_command.split()
135 def _get_command_args(self, args, config_override=None):
136 command_args = self._get_task_command() + ['rc:{0}'.format(self.taskrc_location)]
137 overrides = self.overrides.copy()
138 overrides.update(config_override or dict())
139 for item in overrides.items():
140 command_args.append('rc.{0}={1}'.format(*item))
141 command_args.extend([
142 x.decode('utf-8') if isinstance(x, six.binary_type)
143 else six.text_type(x) for x in args
147 def _get_version(self):
148 p = subprocess.Popen(
149 self._get_task_command() + ['--version'],
150 stdout=subprocess.PIPE,
151 stderr=subprocess.PIPE)
152 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
153 return stdout.strip('\n')
155 def _get_modified_task_fields_as_args(self, task):
158 def add_field(field):
159 # Add the output of format_field method to args list (defaults to
161 serialized_value = task._serialize(field, task._data[field])
163 # Empty values should not be enclosed in quotation marks, see
165 if serialized_value is '':
166 escaped_serialized_value = ''
168 escaped_serialized_value = six.u("'{0}'").format(
171 format_default = lambda task: six.u("{0}:{1}").format(
172 field, escaped_serialized_value)
174 format_func = getattr(self, 'format_{0}'.format(field),
177 args.append(format_func(task))
179 # If we're modifying saved task, simply pass on all modified fields
181 for field in task._modified_fields:
184 # For new tasks, pass all fields that make sense
186 for field in task._data.keys():
187 # We cannot set stuff that's read only (ID, UUID, ..)
188 if field in task.read_only_fields:
190 # We do not want to do field deletion for new tasks
191 if task._data[field] is None:
193 # Otherwise we're fine
198 def format_depends(self, task):
199 # We need to generate added and removed dependencies list,
200 # since Taskwarrior does not accept redefining dependencies.
202 # This cannot be part of serialize_depends, since we need
203 # to keep a list of all depedencies in the _data dictionary,
204 # not just currently added/removed ones
206 old_dependencies = task._original_data.get('depends', set())
208 added = task['depends'] - old_dependencies
209 removed = old_dependencies - task['depends']
211 # Removed dependencies need to be prefixed with '-'
212 return 'depends:' + ','.join(
213 [t['uuid'] for t in added] +
214 ['-' + t['uuid'] for t in removed]
217 def format_description(self, task):
218 # Task version older than 2.4.0 ignores first word of the
219 # task description if description: prefix is used
220 if self.version < self.VERSION_2_4_0:
221 return task._data['description']
223 return six.u("description:'{0}'").format(
224 task._data['description'] or '',
227 def convert_datetime_string(self, value):
229 if self.version >= self.VERSION_2_4_0:
230 # For strings, use 'calc' to evaluate the string to datetime
231 # available since TW 2.4.0
233 result = self.execute_command(['calc'] + args)
234 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
235 localized = local_zone.localize(naive)
238 'Provided value could not be converted to '
239 'datetime, its type is not supported: {}'
240 .format(type(value)),
246 def filter_class(self):
247 return TaskWarriorFilter
253 # First, check if memoized information is available
257 # If not, fetch the config using the 'show' command
258 raw_output = self.execute_command(
260 config_override={'verbose': 'nothing'}
264 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].*$)')
266 for line in raw_output:
267 match = config_regex.match(line)
269 config[match.group('key')] = match.group('value').strip()
271 # Memoize the config dict
272 self._config = ReadOnlyDictView(config)
276 def execute_command(self, args, config_override=None, allow_failure=True,
278 command_args = self._get_command_args(
279 args, config_override=config_override)
280 logger.debug(u' '.join(command_args))
282 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
283 stderr=subprocess.PIPE)
284 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
285 if p.returncode and allow_failure:
287 error_msg = stderr.strip()
289 error_msg = stdout.strip()
290 error_msg += u'\nCommand used: ' + u' '.join(command_args)
291 raise TaskWarriorException(error_msg)
293 # Return all whole triplet only if explicitly asked for
295 return stdout.rstrip().split('\n')
297 return (stdout.rstrip().split('\n'),
298 stderr.rstrip().split('\n'),
301 def enforce_recurrence(self):
302 # Run arbitrary report command which will trigger generation
303 # of recurrent tasks.
305 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
306 if self.version < self.VERSION_2_4_2:
307 self.execute_command(['next'], allow_failure=False)
309 def merge_with(self, path, push=False):
310 path = path.rstrip('/') + '/'
311 self.execute_command(['merge', path], config_override={
312 'merge.autopush': 'yes' if push else 'no',
316 self.execute_command(['undo'])
318 # Backend interface implementation
320 def filter_tasks(self, filter_obj):
321 self.enforce_recurrence()
322 args = ['export'] + filter_obj.get_filter_params()
324 for line in self.execute_command(args):
326 data = line.strip(',')
328 filtered_task = Task(self)
329 filtered_task._load_data(json.loads(data))
330 tasks.append(filtered_task)
332 raise TaskWarriorException('Invalid JSON: %s' % data)
335 def save_task(self, task):
336 """Save a task into TaskWarrior database using add/modify call"""
338 args = [task['uuid'], 'modify'] if task.saved else ['add']
339 args.extend(self._get_modified_task_fields_as_args(task))
340 output = self.execute_command(args)
342 # Parse out the new ID, if the task is being added for the first time
344 id_lines = [l for l in output if l.startswith('Created task ')]
346 # Complain loudly if it seems that more tasks were created
348 # Expected output: Created task 1.
349 # Created task 1 (recurrence template).
350 if len(id_lines) != 1 or len(id_lines[0].split(' ')) not in (3, 5):
351 raise TaskWarriorException(
352 'Unexpected output when creating '
353 'task: %s' % '\n'.join(id_lines),
356 # Circumvent the ID storage, since ID is considered read-only
357 identifier = id_lines[0].split(' ')[2].rstrip('.')
359 # Identifier can be either ID or UUID for completed tasks
361 task._data['id'] = int(identifier)
363 task._data['uuid'] = identifier
365 # Refreshing is very important here, as not only modification time
366 # is updated, but arbitrary attribute may have changed due hooks
367 # altering the data before saving
368 task.refresh(after_save=True)
370 def delete_task(self, task):
371 self.execute_command([task['uuid'], 'delete'])
373 def start_task(self, task):
374 self.execute_command([task['uuid'], 'start'])
376 def stop_task(self, task):
377 self.execute_command([task['uuid'], 'stop'])
379 def complete_task(self, task):
380 # Older versions of TW do not stop active task at completion
381 if self.version < self.VERSION_2_4_0 and task.active:
384 self.execute_command([task['uuid'], 'done'])
386 def annotate_task(self, task, annotation):
387 args = [task['uuid'], 'annotate', annotation]
388 self.execute_command(args)
390 def denotate_task(self, task, annotation):
391 args = [task['uuid'], 'denotate', annotation]
392 self.execute_command(args)
394 def refresh_task(self, task, after_save=False):
395 # We need to use ID as backup for uuid here for the refreshes
396 # of newly saved tasks. Any other place in the code is fine
397 # with using UUID only.
398 args = [task['uuid'] or task['id'], 'export']
399 output = self.execute_command(args)
402 return len(output) == 1 and output[0].startswith('{')
404 # For older TW versions attempt to uniquely locate the task
405 # using the data we have if it has been just saved.
406 # This can happen when adding a completed task on older TW versions.
407 if (not valid(output) and self.version < self.VERSION_2_4_5
410 # Make a copy, removing ID and UUID. It's most likely invalid
411 # (ID 0) if it failed to match a unique task.
412 data = copy.deepcopy(task._data)
414 data.pop('uuid', None)
416 taskfilter = self.filter_class(self)
417 for key, value in data.items():
418 taskfilter.add_filter_param(key, value)
420 output = self.execute_command(['export'] +
421 taskfilter.get_filter_params())
423 # If more than 1 task has been matched still, raise an exception
424 if not valid(output):
425 raise TaskWarriorException(
426 'Unique identifiers {0} with description: {1} matches '
427 'multiple tasks: {2}'.format(
428 task['uuid'] or task['id'], task['description'], output)
431 return json.loads(output[0])
434 self.execute_command(['sync'])