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=None, task_command='task'):
99 self.taskrc_location = None
101 self.taskrc_location = os.path.expanduser(taskrc_location)
103 # If taskrc does not exist, pass / to use defaults and avoid creating
104 # dummy .taskrc file by TaskWarrior
105 if not os.path.exists(self.taskrc_location):
106 self.taskrc_location = '/'
108 self.task_command = task_command
111 self.version = self._get_version()
113 'confirmation': 'no',
114 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
115 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
117 # Defaults to on since 2.4.5, we expect off during parsing
120 # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
121 # arbitrary big number which is likely to be large enough
122 'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
125 # Set data.location override if passed via kwarg
126 if data_location is not None:
127 data_location = os.path.expanduser(data_location)
128 if create and not os.path.exists(data_location):
129 os.makedirs(data_location)
130 self.overrides['data.location'] = data_location
132 self.tasks = TaskQuerySet(self)
134 def _get_task_command(self):
135 return self.task_command.split()
137 def _get_command_args(self, args, config_override=None):
138 command_args = self._get_task_command()
139 overrides = self.overrides.copy()
140 overrides.update(config_override or dict())
141 for item in overrides.items():
142 command_args.append('rc.{0}={1}'.format(*item))
143 command_args.extend([
144 x.decode('utf-8') if isinstance(x, six.binary_type)
145 else six.text_type(x) for x in args
149 def _get_version(self):
150 p = subprocess.Popen(
151 self._get_task_command() + ['--version'],
152 stdout=subprocess.PIPE,
153 stderr=subprocess.PIPE)
154 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
155 return stdout.strip('\n')
157 def _get_modified_task_fields_as_args(self, task):
160 def add_field(field):
161 # Add the output of format_field method to args list (defaults to
163 serialized_value = task._serialize(field, task._data[field])
165 # Empty values should not be enclosed in quotation marks, see
167 if serialized_value is '':
168 escaped_serialized_value = ''
170 escaped_serialized_value = six.u("'{0}'").format(
173 format_default = lambda task: six.u("{0}:{1}").format(
174 field, escaped_serialized_value)
176 format_func = getattr(self, 'format_{0}'.format(field),
179 args.append(format_func(task))
181 # If we're modifying saved task, simply pass on all modified fields
183 for field in task._modified_fields:
186 # For new tasks, pass all fields that make sense
188 for field in task._data.keys():
189 # We cannot set stuff that's read only (ID, UUID, ..)
190 if field in task.read_only_fields:
192 # We do not want to do field deletion for new tasks
193 if task._data[field] is None:
195 # Otherwise we're fine
200 def format_depends(self, task):
201 # We need to generate added and removed dependencies list,
202 # since Taskwarrior does not accept redefining dependencies.
204 # This cannot be part of serialize_depends, since we need
205 # to keep a list of all depedencies in the _data dictionary,
206 # not just currently added/removed ones
208 old_dependencies = task._original_data.get('depends', set())
210 added = task['depends'] - old_dependencies
211 removed = old_dependencies - task['depends']
213 # Removed dependencies need to be prefixed with '-'
214 return 'depends:' + ','.join(
215 [t['uuid'] for t in added] +
216 ['-' + t['uuid'] for t in removed]
219 def format_description(self, task):
220 # Task version older than 2.4.0 ignores first word of the
221 # task description if description: prefix is used
222 if self.version < self.VERSION_2_4_0:
223 return task._data['description']
225 return six.u("description:'{0}'").format(
226 task._data['description'] or '',
229 def convert_datetime_string(self, value):
231 if self.version >= self.VERSION_2_4_0:
232 # For strings, use 'calc' to evaluate the string to datetime
233 # available since TW 2.4.0
235 result = self.execute_command(['calc'] + args)
236 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
237 localized = local_zone.localize(naive)
240 'Provided value could not be converted to '
241 'datetime, its type is not supported: {}'
242 .format(type(value)),
248 def filter_class(self):
249 return TaskWarriorFilter
255 # First, check if memoized information is available
259 # If not, fetch the config using the 'show' command
260 raw_output = self.execute_command(
262 config_override={'verbose': 'nothing'}
266 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].*$)')
268 for line in raw_output:
269 match = config_regex.match(line)
271 config[match.group('key')] = match.group('value').strip()
273 # Memoize the config dict
274 self._config = ReadOnlyDictView(config)
278 def execute_command(self, args, config_override=None, allow_failure=True,
280 command_args = self._get_command_args(
281 args, config_override=config_override)
282 logger.debug(u' '.join(command_args))
284 env = os.environ.copy()
285 if self.taskrc_location:
286 env['TASKRC'] = self.taskrc_location
287 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
288 stderr=subprocess.PIPE, env=env)
289 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
290 if p.returncode and allow_failure:
292 error_msg = stderr.strip()
294 error_msg = stdout.strip()
295 error_msg += u'\nCommand used: ' + u' '.join(command_args)
296 raise TaskWarriorException(error_msg)
298 # Return all whole triplet only if explicitly asked for
300 return stdout.rstrip().split('\n')
302 return (stdout.rstrip().split('\n'),
303 stderr.rstrip().split('\n'),
306 def enforce_recurrence(self):
307 # Run arbitrary report command which will trigger generation
308 # of recurrent tasks.
310 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
311 if self.version < self.VERSION_2_4_2:
312 self.execute_command(['next'], allow_failure=False)
314 def merge_with(self, path, push=False):
315 path = path.rstrip('/') + '/'
316 self.execute_command(['merge', path], config_override={
317 'merge.autopush': 'yes' if push else 'no',
321 self.execute_command(['undo'])
323 # Backend interface implementation
325 def filter_tasks(self, filter_obj):
326 self.enforce_recurrence()
327 args = ['export'] + filter_obj.get_filter_params()
329 for line in self.execute_command(args):
331 data = line.strip(',')
333 filtered_task = Task(self)
334 filtered_task._load_data(json.loads(data))
335 tasks.append(filtered_task)
337 raise TaskWarriorException('Invalid JSON: %s' % data)
340 def save_task(self, task):
341 """Save a task into TaskWarrior database using add/modify call"""
343 args = [task['uuid'], 'modify'] if task.saved else ['add']
344 args.extend(self._get_modified_task_fields_as_args(task))
345 output = self.execute_command(args)
347 # Parse out the new ID, if the task is being added for the first time
349 id_lines = [l for l in output if l.startswith('Created task ')]
351 # Complain loudly if it seems that more tasks were created
353 # Expected output: Created task 1.
354 # Created task 1 (recurrence template).
355 if len(id_lines) != 1 or len(id_lines[0].split(' ')) not in (3, 5):
356 raise TaskWarriorException(
357 'Unexpected output when creating '
358 'task: %s' % '\n'.join(id_lines),
361 # Circumvent the ID storage, since ID is considered read-only
362 identifier = id_lines[0].split(' ')[2].rstrip('.')
364 # Identifier can be either ID or UUID for completed tasks
366 task._data['id'] = int(identifier)
368 task._data['uuid'] = identifier
370 # Refreshing is very important here, as not only modification time
371 # is updated, but arbitrary attribute may have changed due hooks
372 # altering the data before saving
373 task.refresh(after_save=True)
375 def delete_task(self, task):
376 self.execute_command([task['uuid'], 'delete'])
378 def start_task(self, task):
379 self.execute_command([task['uuid'], 'start'])
381 def stop_task(self, task):
382 self.execute_command([task['uuid'], 'stop'])
384 def complete_task(self, task):
385 # Older versions of TW do not stop active task at completion
386 if self.version < self.VERSION_2_4_0 and task.active:
389 self.execute_command([task['uuid'], 'done'])
391 def annotate_task(self, task, annotation):
392 args = [task['uuid'], 'annotate', annotation]
393 self.execute_command(args)
395 def denotate_task(self, task, annotation):
396 args = [task['uuid'], 'denotate', annotation]
397 self.execute_command(args)
399 def refresh_task(self, task, after_save=False):
400 # We need to use ID as backup for uuid here for the refreshes
401 # of newly saved tasks. Any other place in the code is fine
402 # with using UUID only.
403 args = [task['uuid'] or task['id'], 'export']
404 output = self.execute_command(args)
407 return len(output) == 1 and output[0].startswith('{')
409 # For older TW versions attempt to uniquely locate the task
410 # using the data we have if it has been just saved.
411 # This can happen when adding a completed task on older TW versions.
412 if (not valid(output) and self.version < self.VERSION_2_4_5
415 # Make a copy, removing ID and UUID. It's most likely invalid
416 # (ID 0) if it failed to match a unique task.
417 data = copy.deepcopy(task._data)
419 data.pop('uuid', None)
421 taskfilter = self.filter_class(self)
422 for key, value in data.items():
423 taskfilter.add_filter_param(key, value)
425 output = self.execute_command(['export'] +
426 taskfilter.get_filter_params())
428 # If more than 1 task has been matched still, raise an exception
429 if not valid(output):
430 raise TaskWarriorException(
431 'Unique identifiers {0} with description: {1} matches '
432 'multiple tasks: {2}'.format(
433 task['uuid'] or task['id'], task['description'], output)
436 return json.loads(output[0])
439 self.execute_command(['sync'])