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.
10 from .task import Task, TaskQuerySet
11 from .filters import TaskWarriorFilter
12 from .serializing import local_zone
14 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
16 logger = logging.getLogger(__name__)
18 class Backend(object):
21 def filter_class(self):
22 """Returns the TaskFilter class used by this backend"""
26 def filter_tasks(self, filter_obj):
27 """Returns a list of Task objects matching the given filter"""
31 def save_task(self, task):
35 def delete_task(self, task):
39 def start_task(self, task):
43 def stop_task(self, task):
47 def complete_task(self, task):
51 def refresh_task(self, task, after_save=False):
53 Refreshes the given task. Returns new data dict with serialized
59 def annotate_task(self, task, annotation):
63 def denotate_task(self, task, annotation):
68 """Syncs the backend database with the taskd server"""
71 def convert_datetime_string(self, value):
73 Converts TW syntax datetime string to a localized datetime
74 object. This method is not mandatory.
79 class TaskWarriorException(Exception):
83 class TaskWarrior(object):
85 VERSION_2_1_0 = six.u('2.1.0')
86 VERSION_2_2_0 = six.u('2.2.0')
87 VERSION_2_3_0 = six.u('2.3.0')
88 VERSION_2_4_0 = six.u('2.4.0')
89 VERSION_2_4_1 = six.u('2.4.1')
90 VERSION_2_4_2 = six.u('2.4.2')
91 VERSION_2_4_3 = six.u('2.4.3')
92 VERSION_2_4_4 = six.u('2.4.4')
93 VERSION_2_4_5 = six.u('2.4.5')
95 def __init__(self, data_location=None, create=True, taskrc_location='~/.taskrc'):
96 self.taskrc_location = os.path.expanduser(taskrc_location)
98 # If taskrc does not exist, pass / to use defaults and avoid creating
99 # dummy .taskrc file by TaskWarrior
100 if not os.path.exists(self.taskrc_location):
101 self.taskrc_location = '/'
103 self.version = self._get_version()
105 'confirmation': 'no',
106 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
107 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
109 # Defaults to on since 2.4.5, we expect off during parsing
112 # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
113 # arbitrary big number which is likely to be large enough
114 'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
117 # Set data.location override if passed via kwarg
118 if data_location is not None:
119 data_location = os.path.expanduser(data_location)
120 if create and not os.path.exists(data_location):
121 os.makedirs(data_location)
122 self.config['data.location'] = data_location
124 self.tasks = TaskQuerySet(self)
126 def _get_command_args(self, args, config_override=None):
127 command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
128 config = self.config.copy()
129 config.update(config_override or dict())
130 for item in config.items():
131 command_args.append('rc.{0}={1}'.format(*item))
132 command_args.extend(map(six.text_type, args))
135 def _get_version(self):
136 p = subprocess.Popen(
137 ['task', '--version'],
138 stdout=subprocess.PIPE,
139 stderr=subprocess.PIPE)
140 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
141 return stdout.strip('\n')
143 def _get_modified_task_fields_as_args(self, task):
146 def add_field(field):
147 # Add the output of format_field method to args list (defaults to
149 serialized_value = task._serialize(field, task._data[field])
151 # Empty values should not be enclosed in quotation marks, see
153 if serialized_value is '':
154 escaped_serialized_value = ''
156 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
158 format_default = lambda task: six.u("{0}:{1}").format(field,
159 escaped_serialized_value)
161 format_func = getattr(self, 'format_{0}'.format(field),
164 args.append(format_func(task))
166 # If we're modifying saved task, simply pass on all modified fields
168 for field in task._modified_fields:
170 # For new tasks, pass all fields that make sense
172 for field in task._data.keys():
173 if field in task.read_only_fields:
179 def format_depends(self, task):
180 # We need to generate added and removed dependencies list,
181 # since Taskwarrior does not accept redefining dependencies.
183 # This cannot be part of serialize_depends, since we need
184 # to keep a list of all depedencies in the _data dictionary,
185 # not just currently added/removed ones
187 old_dependencies = task._original_data.get('depends', set())
189 added = task['depends'] - old_dependencies
190 removed = old_dependencies - task['depends']
192 # Removed dependencies need to be prefixed with '-'
193 return 'depends:' + ','.join(
194 [t['uuid'] for t in added] +
195 ['-' + t['uuid'] for t in removed]
198 def format_description(self, task):
199 # Task version older than 2.4.0 ignores first word of the
200 # task description if description: prefix is used
201 if self.version < self.VERSION_2_4_0:
202 return task._data['description']
204 return six.u("description:'{0}'").format(task._data['description'] or '')
206 def convert_datetime_string(self, value):
208 if self.version >= self.VERSION_2_4_0:
209 # For strings, use 'task calc' to evaluate the string to datetime
210 # available since TW 2.4.0
212 result = self.execute_command(['calc'] + args)
213 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
214 localized = local_zone.localize(naive)
216 raise ValueError("Provided value could not be converted to "
217 "datetime, its type is not supported: {}"
218 .format(type(value)))
223 def filter_class(self):
224 return TaskWarriorFilter
228 def get_config(self):
229 raw_output = self.execute_command(
231 config_override={'verbose': 'nothing'}
235 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
237 for line in raw_output:
238 match = config_regex.match(line)
240 config[match.group('key')] = match.group('value').strip()
244 def execute_command(self, args, config_override=None, allow_failure=True,
246 command_args = self._get_command_args(
247 args, config_override=config_override)
248 logger.debug(' '.join(command_args))
249 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
250 stderr=subprocess.PIPE)
251 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
252 if p.returncode and allow_failure:
254 error_msg = stderr.strip()
256 error_msg = stdout.strip()
257 raise TaskWarriorException(error_msg)
259 # Return all whole triplet only if explicitly asked for
261 return stdout.rstrip().split('\n')
263 return (stdout.rstrip().split('\n'),
264 stderr.rstrip().split('\n'),
267 def enforce_recurrence(self):
268 # Run arbitrary report command which will trigger generation
269 # of recurrent tasks.
271 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
272 if self.version < self.VERSION_2_4_2:
273 self.execute_command(['next'], allow_failure=False)
275 def merge_with(self, path, push=False):
276 path = path.rstrip('/') + '/'
277 self.execute_command(['merge', path], config_override={
278 'merge.autopush': 'yes' if push else 'no',
282 self.execute_command(['undo'])
284 # Backend interface implementation
286 def filter_tasks(self, filter_obj):
287 self.enforce_recurrence()
288 args = ['export', '--'] + filter_obj.get_filter_params()
290 for line in self.execute_command(args):
292 data = line.strip(',')
294 filtered_task = Task(self)
295 filtered_task._load_data(json.loads(data))
296 tasks.append(filtered_task)
298 raise TaskWarriorException('Invalid JSON: %s' % data)
301 def save_task(self, task):
302 """Save a task into TaskWarrior database using add/modify call"""
304 args = [task['uuid'], 'modify'] if task.saved else ['add']
305 args.extend(self._get_modified_task_fields_as_args(task))
306 output = self.execute_command(args)
308 # Parse out the new ID, if the task is being added for the first time
310 id_lines = [l for l in output if l.startswith('Created task ')]
312 # Complain loudly if it seems that more tasks were created
314 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
315 raise TaskWarriorException("Unexpected output when creating "
316 "task: %s" % '\n'.join(id_lines))
318 # Circumvent the ID storage, since ID is considered read-only
319 identifier = id_lines[0].split(' ')[2].rstrip('.')
321 # Identifier can be either ID or UUID for completed tasks
323 task._data['id'] = int(identifier)
325 task._data['uuid'] = identifier
327 # Refreshing is very important here, as not only modification time
328 # is updated, but arbitrary attribute may have changed due hooks
329 # altering the data before saving
330 task.refresh(after_save=True)
332 def delete_task(self, task):
333 self.execute_command([task['uuid'], 'delete'])
335 def start_task(self, task):
336 self.execute_command([task['uuid'], 'start'])
338 def stop_task(self, task):
339 self.execute_command([task['uuid'], 'stop'])
341 def complete_task(self, task):
342 # Older versions of TW do not stop active task at completion
343 if self.version < self.VERSION_2_4_0 and task.active:
346 self.execute_command([task['uuid'], 'done'])
348 def annotate_task(self, task, annotation):
349 args = [task['uuid'], 'annotate', annotation]
350 self.execute_command(args)
352 def denotate_task(self, task, annotation):
353 args = [task['uuid'], 'denotate', annotation]
354 self.execute_command(args)
356 def refresh_task(self, task, after_save=False):
357 # We need to use ID as backup for uuid here for the refreshes
358 # of newly saved tasks. Any other place in the code is fine
359 # with using UUID only.
360 args = [task['uuid'] or task['id'], 'export']
361 output = self.execute_command(args)
364 return len(output) == 1 and output[0].startswith('{')
366 # For older TW versions attempt to uniquely locate the task
367 # using the data we have if it has been just saved.
368 # This can happen when adding a completed task on older TW versions.
369 if (not valid(output) and self.version < self.VERSION_2_4_5
372 # Make a copy, removing ID and UUID. It's most likely invalid
373 # (ID 0) if it failed to match a unique task.
374 data = copy.deepcopy(task._data)
376 data.pop('uuid', None)
378 taskfilter = self.filter_class(self)
379 for key, value in data.items():
380 taskfilter.add_filter_param(key, value)
382 output = self.execute_command(['export', '--'] +
383 taskfilter.get_filter_params())
385 # If more than 1 task has been matched still, raise an exception
386 if not valid(output):
387 raise TaskWarriorException(
388 "Unique identifiers {0} with description: {1} matches "
389 "multiple tasks: {2}".format(
390 task['uuid'] or task['id'], task['description'], output)
393 return json.loads(output[0])
396 self.execute_command(['sync'])