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.
12 from .task import Task, TaskQuerySet
13 from .filters import TaskWarriorFilter
14 from .serializing import local_zone
16 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
18 logger = logging.getLogger(__name__)
21 class Backend(object):
24 def filter_class(self):
25 """Returns the TaskFilter class used by this backend"""
29 def filter_tasks(self, filter_obj):
30 """Returns a list of Task objects matching the given filter"""
34 def save_task(self, task):
38 def delete_task(self, task):
42 def start_task(self, task):
46 def stop_task(self, task):
50 def complete_task(self, task):
54 def refresh_task(self, task, after_save=False):
56 Refreshes the given task. Returns new data dict with serialized
62 def annotate_task(self, task, annotation):
66 def denotate_task(self, task, annotation):
71 """Syncs the backend database with the taskd server"""
74 def convert_datetime_string(self, value):
76 Converts TW syntax datetime string to a localized datetime
77 object. This method is not mandatory.
82 class TaskWarriorException(Exception):
86 class TaskWarrior(Backend):
88 VERSION_2_1_0 = six.u('2.1.0')
89 VERSION_2_2_0 = six.u('2.2.0')
90 VERSION_2_3_0 = six.u('2.3.0')
91 VERSION_2_4_0 = six.u('2.4.0')
92 VERSION_2_4_1 = six.u('2.4.1')
93 VERSION_2_4_2 = six.u('2.4.2')
94 VERSION_2_4_3 = six.u('2.4.3')
95 VERSION_2_4_4 = six.u('2.4.4')
96 VERSION_2_4_5 = six.u('2.4.5')
98 def __init__(self, data_location=None, create=True, 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.config['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 config = self.config.copy()
133 config.update(config_override or dict())
134 for item in config.items():
135 command_args.append('rc.{0}={1}'.format(*item))
136 command_args.extend(map(six.text_type, args))
139 def _get_version(self):
140 p = subprocess.Popen(
141 ['task', '--version'],
142 stdout=subprocess.PIPE,
143 stderr=subprocess.PIPE)
144 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
145 return stdout.strip('\n')
147 def _get_modified_task_fields_as_args(self, task):
150 def add_field(field):
151 # Add the output of format_field method to args list (defaults to
153 serialized_value = task._serialize(field, task._data[field])
155 # Empty values should not be enclosed in quotation marks, see
157 if serialized_value is '':
158 escaped_serialized_value = ''
160 escaped_serialized_value = six.u("'{0}'").format(
163 format_default = lambda task: six.u("{0}:{1}").format(
164 field, escaped_serialized_value)
166 format_func = getattr(self, 'format_{0}'.format(field),
169 args.append(format_func(task))
171 # If we're modifying saved task, simply pass on all modified fields
173 for field in task._modified_fields:
175 # For new tasks, pass all fields that make sense
177 for field in task._data.keys():
178 if field in task.read_only_fields:
184 def format_depends(self, task):
185 # We need to generate added and removed dependencies list,
186 # since Taskwarrior does not accept redefining dependencies.
188 # This cannot be part of serialize_depends, since we need
189 # to keep a list of all depedencies in the _data dictionary,
190 # not just currently added/removed ones
192 old_dependencies = task._original_data.get('depends', set())
194 added = task['depends'] - old_dependencies
195 removed = old_dependencies - task['depends']
197 # Removed dependencies need to be prefixed with '-'
198 return 'depends:' + ','.join(
199 [t['uuid'] for t in added] +
200 ['-' + t['uuid'] for t in removed]
203 def format_description(self, task):
204 # Task version older than 2.4.0 ignores first word of the
205 # task description if description: prefix is used
206 if self.version < self.VERSION_2_4_0:
207 return task._data['description']
209 return six.u("description:'{0}'").format(task._data['description'] or '')
211 def convert_datetime_string(self, value):
213 if self.version >= self.VERSION_2_4_0:
214 # For strings, use 'task calc' to evaluate the string to datetime
215 # available since TW 2.4.0
217 result = self.execute_command(['calc'] + args)
218 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
219 localized = local_zone.localize(naive)
221 raise ValueError("Provided value could not be converted to "
222 "datetime, its type is not supported: {}"
223 .format(type(value)))
228 def filter_class(self):
229 return TaskWarriorFilter
235 # First, check if memoized information is available
237 return copy.deepcopy(self._config)
239 # If not, fetch the config using the 'show' command
240 raw_output = self.execute_command(
242 config_override={'verbose': 'nothing'}
246 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
248 for line in raw_output:
249 match = config_regex.match(line)
251 config[match.group('key')] = match.group('value').strip()
253 # Memoize the config dict
254 self._config = config
256 return copy.deepcopy(config)
258 def execute_command(self, args, config_override=None, allow_failure=True,
260 command_args = self._get_command_args(
261 args, config_override=config_override)
262 logger.debug(' '.join(command_args))
263 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
264 stderr=subprocess.PIPE)
265 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
266 if p.returncode and allow_failure:
268 error_msg = stderr.strip()
270 error_msg = stdout.strip()
271 raise TaskWarriorException(error_msg)
273 # Return all whole triplet only if explicitly asked for
275 return stdout.rstrip().split('\n')
277 return (stdout.rstrip().split('\n'),
278 stderr.rstrip().split('\n'),
281 def enforce_recurrence(self):
282 # Run arbitrary report command which will trigger generation
283 # of recurrent tasks.
285 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
286 if self.version < self.VERSION_2_4_2:
287 self.execute_command(['next'], allow_failure=False)
289 def merge_with(self, path, push=False):
290 path = path.rstrip('/') + '/'
291 self.execute_command(['merge', path], config_override={
292 'merge.autopush': 'yes' if push else 'no',
296 self.execute_command(['undo'])
298 # Backend interface implementation
300 def filter_tasks(self, filter_obj):
301 self.enforce_recurrence()
302 args = ['export', '--'] + filter_obj.get_filter_params()
304 for line in self.execute_command(args):
306 data = line.strip(',')
308 filtered_task = Task(self)
309 filtered_task._load_data(json.loads(data))
310 tasks.append(filtered_task)
312 raise TaskWarriorException('Invalid JSON: %s' % data)
315 def save_task(self, task):
316 """Save a task into TaskWarrior database using add/modify call"""
318 args = [task['uuid'], 'modify'] if task.saved else ['add']
319 args.extend(self._get_modified_task_fields_as_args(task))
320 output = self.execute_command(args)
322 # Parse out the new ID, if the task is being added for the first time
324 id_lines = [l for l in output if l.startswith('Created task ')]
326 # Complain loudly if it seems that more tasks were created
328 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
329 raise TaskWarriorException("Unexpected output when creating "
330 "task: %s" % '\n'.join(id_lines))
332 # Circumvent the ID storage, since ID is considered read-only
333 identifier = id_lines[0].split(' ')[2].rstrip('.')
335 # Identifier can be either ID or UUID for completed tasks
337 task._data['id'] = int(identifier)
339 task._data['uuid'] = identifier
341 # Refreshing is very important here, as not only modification time
342 # is updated, but arbitrary attribute may have changed due hooks
343 # altering the data before saving
344 task.refresh(after_save=True)
346 def delete_task(self, task):
347 self.execute_command([task['uuid'], 'delete'])
349 def start_task(self, task):
350 self.execute_command([task['uuid'], 'start'])
352 def stop_task(self, task):
353 self.execute_command([task['uuid'], 'stop'])
355 def complete_task(self, task):
356 # Older versions of TW do not stop active task at completion
357 if self.version < self.VERSION_2_4_0 and task.active:
360 self.execute_command([task['uuid'], 'done'])
362 def annotate_task(self, task, annotation):
363 args = [task['uuid'], 'annotate', annotation]
364 self.execute_command(args)
366 def denotate_task(self, task, annotation):
367 args = [task['uuid'], 'denotate', annotation]
368 self.execute_command(args)
370 def refresh_task(self, task, after_save=False):
371 # We need to use ID as backup for uuid here for the refreshes
372 # of newly saved tasks. Any other place in the code is fine
373 # with using UUID only.
374 args = [task['uuid'] or task['id'], 'export']
375 output = self.execute_command(args)
378 return len(output) == 1 and output[0].startswith('{')
380 # For older TW versions attempt to uniquely locate the task
381 # using the data we have if it has been just saved.
382 # This can happen when adding a completed task on older TW versions.
383 if (not valid(output) and self.version < self.VERSION_2_4_5
386 # Make a copy, removing ID and UUID. It's most likely invalid
387 # (ID 0) if it failed to match a unique task.
388 data = copy.deepcopy(task._data)
390 data.pop('uuid', None)
392 taskfilter = self.filter_class(self)
393 for key, value in data.items():
394 taskfilter.add_filter_param(key, value)
396 output = self.execute_command(['export', '--'] +
397 taskfilter.get_filter_params())
399 # If more than 1 task has been matched still, raise an exception
400 if not valid(output):
401 raise TaskWarriorException(
402 "Unique identifiers {0} with description: {1} matches "
403 "multiple tasks: {2}".format(
404 task['uuid'] or task['id'], task['description'], output)
407 return json.loads(output[0])
410 self.execute_command(['sync'])