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, taskrc_location='~/.taskrc'):
98 self.taskrc_location = os.path.expanduser(taskrc_location)
100 # If taskrc does not exist, pass / to use defaults and avoid creating
101 # dummy .taskrc file by TaskWarrior
102 if not os.path.exists(self.taskrc_location):
103 self.taskrc_location = '/'
106 self.version = self._get_version()
108 'confirmation': 'no',
109 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
110 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
112 # Defaults to on since 2.4.5, we expect off during parsing
115 # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
116 # arbitrary big number which is likely to be large enough
117 'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
120 # Set data.location override if passed via kwarg
121 if data_location is not None:
122 data_location = os.path.expanduser(data_location)
123 if create and not os.path.exists(data_location):
124 os.makedirs(data_location)
125 self.overrides['data.location'] = data_location
127 self.tasks = TaskQuerySet(self)
129 def _get_command_args(self, args, config_override=None):
130 command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
131 overrides = self.overrides.copy()
132 overrides.update(config_override or dict())
133 for item in overrides.items():
134 command_args.append('rc.{0}={1}'.format(*item))
135 command_args.extend([
136 x.decode('utf-8') if isinstance(x, six.binary_type)
137 else six.text_type(x) for x in args
141 def _get_version(self):
142 p = subprocess.Popen(
143 ['task', '--version'],
144 stdout=subprocess.PIPE,
145 stderr=subprocess.PIPE)
146 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
147 return stdout.strip('\n')
149 def _get_modified_task_fields_as_args(self, task):
152 def add_field(field):
153 # Add the output of format_field method to args list (defaults to
155 serialized_value = task._serialize(field, task._data[field])
157 # Empty values should not be enclosed in quotation marks, see
159 if serialized_value is '':
160 escaped_serialized_value = ''
162 escaped_serialized_value = six.u("'{0}'").format(
165 format_default = lambda task: six.u("{0}:{1}").format(
166 field, escaped_serialized_value)
168 format_func = getattr(self, 'format_{0}'.format(field),
171 args.append(format_func(task))
173 # If we're modifying saved task, simply pass on all modified fields
175 for field in task._modified_fields:
178 # For new tasks, pass all fields that make sense
180 for field in task._data.keys():
181 # We cannot set stuff that's read only (ID, UUID, ..)
182 if field in task.read_only_fields:
184 # We do not want to do field deletion for new tasks
185 if task._data[field] is None:
187 # Otherwise we're fine
192 def format_depends(self, task):
193 # We need to generate added and removed dependencies list,
194 # since Taskwarrior does not accept redefining dependencies.
196 # This cannot be part of serialize_depends, since we need
197 # to keep a list of all depedencies in the _data dictionary,
198 # not just currently added/removed ones
200 old_dependencies = task._original_data.get('depends', set())
202 added = task['depends'] - old_dependencies
203 removed = old_dependencies - task['depends']
205 # Removed dependencies need to be prefixed with '-'
206 return 'depends:' + ','.join(
207 [t['uuid'] for t in added] +
208 ['-' + t['uuid'] for t in removed]
211 def format_description(self, task):
212 # Task version older than 2.4.0 ignores first word of the
213 # task description if description: prefix is used
214 if self.version < self.VERSION_2_4_0:
215 return task._data['description']
217 return six.u("description:'{0}'").format(task._data['description'] or '')
219 def convert_datetime_string(self, value):
221 if self.version >= self.VERSION_2_4_0:
222 # For strings, use 'task calc' to evaluate the string to datetime
223 # available since TW 2.4.0
225 result = self.execute_command(['calc'] + args)
226 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
227 localized = local_zone.localize(naive)
229 raise ValueError("Provided value could not be converted to "
230 "datetime, its type is not supported: {}"
231 .format(type(value)))
236 def filter_class(self):
237 return TaskWarriorFilter
243 # First, check if memoized information is available
247 # If not, fetch the config using the 'show' command
248 raw_output = self.execute_command(
250 config_override={'verbose': 'nothing'}
254 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
256 for line in raw_output:
257 match = config_regex.match(line)
259 config[match.group('key')] = match.group('value').strip()
261 # Memoize the config dict
262 self._config = ReadOnlyDictView(config)
266 def execute_command(self, args, config_override=None, allow_failure=True,
268 command_args = self._get_command_args(
269 args, config_override=config_override)
270 logger.debug(u' '.join(command_args))
272 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
273 stderr=subprocess.PIPE)
274 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
275 if p.returncode and allow_failure:
277 error_msg = stderr.strip()
279 error_msg = stdout.strip()
280 error_msg += u'\nCommand used: ' + u' '.join(command_args)
281 raise TaskWarriorException(error_msg)
283 # Return all whole triplet only if explicitly asked for
285 return stdout.rstrip().split('\n')
287 return (stdout.rstrip().split('\n'),
288 stderr.rstrip().split('\n'),
291 def enforce_recurrence(self):
292 # Run arbitrary report command which will trigger generation
293 # of recurrent tasks.
295 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
296 if self.version < self.VERSION_2_4_2:
297 self.execute_command(['next'], allow_failure=False)
299 def merge_with(self, path, push=False):
300 path = path.rstrip('/') + '/'
301 self.execute_command(['merge', path], config_override={
302 'merge.autopush': 'yes' if push else 'no',
306 self.execute_command(['undo'])
308 # Backend interface implementation
310 def filter_tasks(self, filter_obj):
311 self.enforce_recurrence()
312 args = ['export'] + filter_obj.get_filter_params()
314 for line in self.execute_command(args):
316 data = line.strip(',')
318 filtered_task = Task(self)
319 filtered_task._load_data(json.loads(data))
320 tasks.append(filtered_task)
322 raise TaskWarriorException('Invalid JSON: %s' % data)
325 def save_task(self, task):
326 """Save a task into TaskWarrior database using add/modify call"""
328 args = [task['uuid'], 'modify'] if task.saved else ['add']
329 args.extend(self._get_modified_task_fields_as_args(task))
330 output = self.execute_command(args)
332 # Parse out the new ID, if the task is being added for the first time
334 id_lines = [l for l in output if l.startswith('Created task ')]
336 # Complain loudly if it seems that more tasks were created
338 # Expected output: Created task 1.
339 # Created task 1 (recurrence template).
340 if len(id_lines) != 1 or len(id_lines[0].split(' ')) not in (3, 5):
341 raise TaskWarriorException("Unexpected output when creating "
342 "task: %s" % '\n'.join(id_lines))
344 # Circumvent the ID storage, since ID is considered read-only
345 identifier = id_lines[0].split(' ')[2].rstrip('.')
347 # Identifier can be either ID or UUID for completed tasks
349 task._data['id'] = int(identifier)
351 task._data['uuid'] = identifier
353 # Refreshing is very important here, as not only modification time
354 # is updated, but arbitrary attribute may have changed due hooks
355 # altering the data before saving
356 task.refresh(after_save=True)
358 def delete_task(self, task):
359 self.execute_command([task['uuid'], 'delete'])
361 def start_task(self, task):
362 self.execute_command([task['uuid'], 'start'])
364 def stop_task(self, task):
365 self.execute_command([task['uuid'], 'stop'])
367 def complete_task(self, task):
368 # Older versions of TW do not stop active task at completion
369 if self.version < self.VERSION_2_4_0 and task.active:
372 self.execute_command([task['uuid'], 'done'])
374 def annotate_task(self, task, annotation):
375 args = [task['uuid'], 'annotate', annotation]
376 self.execute_command(args)
378 def denotate_task(self, task, annotation):
379 args = [task['uuid'], 'denotate', annotation]
380 self.execute_command(args)
382 def refresh_task(self, task, after_save=False):
383 # We need to use ID as backup for uuid here for the refreshes
384 # of newly saved tasks. Any other place in the code is fine
385 # with using UUID only.
386 args = [task['uuid'] or task['id'], 'export']
387 output = self.execute_command(args)
390 return len(output) == 1 and output[0].startswith('{')
392 # For older TW versions attempt to uniquely locate the task
393 # using the data we have if it has been just saved.
394 # This can happen when adding a completed task on older TW versions.
395 if (not valid(output) and self.version < self.VERSION_2_4_5
398 # Make a copy, removing ID and UUID. It's most likely invalid
399 # (ID 0) if it failed to match a unique task.
400 data = copy.deepcopy(task._data)
402 data.pop('uuid', None)
404 taskfilter = self.filter_class(self)
405 for key, value in data.items():
406 taskfilter.add_filter_param(key, value)
408 output = self.execute_command(['export'] +
409 taskfilter.get_filter_params())
411 # If more than 1 task has been matched still, raise an exception
412 if not valid(output):
413 raise TaskWarriorException(
414 "Unique identifiers {0} with description: {1} matches "
415 "multiple tasks: {2}".format(
416 task['uuid'] or task['id'], task['description'], output)
419 return json.loads(output[0])
422 self.execute_command(['sync'])