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__)
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.
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 = '/'
105 self.version = self._get_version()
107 'confirmation': 'no',
108 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
109 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
111 # Defaults to on since 2.4.5, we expect off during parsing
114 # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
115 # arbitrary big number which is likely to be large enough
116 'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
119 # Set data.location override if passed via kwarg
120 if data_location is not None:
121 data_location = os.path.expanduser(data_location)
122 if create and not os.path.exists(data_location):
123 os.makedirs(data_location)
124 self.config['data.location'] = data_location
126 self.tasks = TaskQuerySet(self)
128 def _get_command_args(self, args, config_override=None):
129 command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
130 config = self.config.copy()
131 config.update(config_override or dict())
132 for item in config.items():
133 command_args.append('rc.{0}={1}'.format(*item))
134 command_args.extend(map(six.text_type, args))
137 def _get_version(self):
138 p = subprocess.Popen(
139 ['task', '--version'],
140 stdout=subprocess.PIPE,
141 stderr=subprocess.PIPE)
142 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
143 return stdout.strip('\n')
145 def _get_modified_task_fields_as_args(self, task):
148 def add_field(field):
149 # Add the output of format_field method to args list (defaults to
151 serialized_value = task._serialize(field, task._data[field])
153 # Empty values should not be enclosed in quotation marks, see
155 if serialized_value is '':
156 escaped_serialized_value = ''
158 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
160 format_default = lambda task: six.u("{0}:{1}").format(field,
161 escaped_serialized_value)
163 format_func = getattr(self, 'format_{0}'.format(field),
166 args.append(format_func(task))
168 # If we're modifying saved task, simply pass on all modified fields
170 for field in task._modified_fields:
172 # For new tasks, pass all fields that make sense
174 for field in task._data.keys():
175 if field in task.read_only_fields:
181 def format_depends(self, task):
182 # We need to generate added and removed dependencies list,
183 # since Taskwarrior does not accept redefining dependencies.
185 # This cannot be part of serialize_depends, since we need
186 # to keep a list of all depedencies in the _data dictionary,
187 # not just currently added/removed ones
189 old_dependencies = task._original_data.get('depends', set())
191 added = task['depends'] - old_dependencies
192 removed = old_dependencies - task['depends']
194 # Removed dependencies need to be prefixed with '-'
195 return 'depends:' + ','.join(
196 [t['uuid'] for t in added] +
197 ['-' + t['uuid'] for t in removed]
200 def format_description(self, task):
201 # Task version older than 2.4.0 ignores first word of the
202 # task description if description: prefix is used
203 if self.version < self.VERSION_2_4_0:
204 return task._data['description']
206 return six.u("description:'{0}'").format(task._data['description'] or '')
208 def convert_datetime_string(self, value):
210 if self.version >= self.VERSION_2_4_0:
211 # For strings, use 'task calc' to evaluate the string to datetime
212 # available since TW 2.4.0
214 result = self.execute_command(['calc'] + args)
215 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
216 localized = local_zone.localize(naive)
218 raise ValueError("Provided value could not be converted to "
219 "datetime, its type is not supported: {}"
220 .format(type(value)))
225 def filter_class(self):
226 return TaskWarriorFilter
230 def get_config(self):
231 raw_output = self.execute_command(
233 config_override={'verbose': 'nothing'}
237 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
239 for line in raw_output:
240 match = config_regex.match(line)
242 config[match.group('key')] = match.group('value').strip()
246 def execute_command(self, args, config_override=None, allow_failure=True,
248 command_args = self._get_command_args(
249 args, config_override=config_override)
250 logger.debug(' '.join(command_args))
251 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
252 stderr=subprocess.PIPE)
253 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
254 if p.returncode and allow_failure:
256 error_msg = stderr.strip()
258 error_msg = stdout.strip()
259 raise TaskWarriorException(error_msg)
261 # Return all whole triplet only if explicitly asked for
263 return stdout.rstrip().split('\n')
265 return (stdout.rstrip().split('\n'),
266 stderr.rstrip().split('\n'),
269 def enforce_recurrence(self):
270 # Run arbitrary report command which will trigger generation
271 # of recurrent tasks.
273 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
274 if self.version < self.VERSION_2_4_2:
275 self.execute_command(['next'], allow_failure=False)
277 def merge_with(self, path, push=False):
278 path = path.rstrip('/') + '/'
279 self.execute_command(['merge', path], config_override={
280 'merge.autopush': 'yes' if push else 'no',
284 self.execute_command(['undo'])
286 # Backend interface implementation
288 def filter_tasks(self, filter_obj):
289 self.enforce_recurrence()
290 args = ['export', '--'] + filter_obj.get_filter_params()
292 for line in self.execute_command(args):
294 data = line.strip(',')
296 filtered_task = Task(self)
297 filtered_task._load_data(json.loads(data))
298 tasks.append(filtered_task)
300 raise TaskWarriorException('Invalid JSON: %s' % data)
303 def save_task(self, task):
304 """Save a task into TaskWarrior database using add/modify call"""
306 args = [task['uuid'], 'modify'] if task.saved else ['add']
307 args.extend(self._get_modified_task_fields_as_args(task))
308 output = self.execute_command(args)
310 # Parse out the new ID, if the task is being added for the first time
312 id_lines = [l for l in output if l.startswith('Created task ')]
314 # Complain loudly if it seems that more tasks were created
316 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
317 raise TaskWarriorException("Unexpected output when creating "
318 "task: %s" % '\n'.join(id_lines))
320 # Circumvent the ID storage, since ID is considered read-only
321 identifier = id_lines[0].split(' ')[2].rstrip('.')
323 # Identifier can be either ID or UUID for completed tasks
325 task._data['id'] = int(identifier)
327 task._data['uuid'] = identifier
329 # Refreshing is very important here, as not only modification time
330 # is updated, but arbitrary attribute may have changed due hooks
331 # altering the data before saving
332 task.refresh(after_save=True)
334 def delete_task(self, task):
335 self.execute_command([task['uuid'], 'delete'])
337 def start_task(self, task):
338 self.execute_command([task['uuid'], 'start'])
340 def stop_task(self, task):
341 self.execute_command([task['uuid'], 'stop'])
343 def complete_task(self, task):
344 # Older versions of TW do not stop active task at completion
345 if self.version < self.VERSION_2_4_0 and task.active:
348 self.execute_command([task['uuid'], 'done'])
350 def annotate_task(self, task, annotation):
351 args = [task['uuid'], 'annotate', annotation]
352 self.execute_command(args)
354 def denotate_task(self, task, annotation):
355 args = [task['uuid'], 'denotate', annotation]
356 self.execute_command(args)
358 def refresh_task(self, task, after_save=False):
359 # We need to use ID as backup for uuid here for the refreshes
360 # of newly saved tasks. Any other place in the code is fine
361 # with using UUID only.
362 args = [task['uuid'] or task['id'], 'export']
363 output = self.execute_command(args)
366 return len(output) == 1 and output[0].startswith('{')
368 # For older TW versions attempt to uniquely locate the task
369 # using the data we have if it has been just saved.
370 # This can happen when adding a completed task on older TW versions.
371 if (not valid(output) and self.version < self.VERSION_2_4_5
374 # Make a copy, removing ID and UUID. It's most likely invalid
375 # (ID 0) if it failed to match a unique task.
376 data = copy.deepcopy(task._data)
378 data.pop('uuid', None)
380 taskfilter = self.filter_class(self)
381 for key, value in data.items():
382 taskfilter.add_filter_param(key, value)
384 output = self.execute_command(['export', '--'] +
385 taskfilter.get_filter_params())
387 # If more than 1 task has been matched still, raise an exception
388 if not valid(output):
389 raise TaskWarriorException(
390 "Unique identifiers {0} with description: {1} matches "
391 "multiple tasks: {2}".format(
392 task['uuid'] or task['id'], task['description'], output)
395 return json.loads(output[0])
398 self.execute_command(['sync'])