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'])