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 VERSION_2_1_0 = six.u('2.1.0')
17 VERSION_2_2_0 = six.u('2.2.0')
18 VERSION_2_3_0 = six.u('2.3.0')
19 VERSION_2_4_0 = six.u('2.4.0')
20 VERSION_2_4_1 = six.u('2.4.1')
21 VERSION_2_4_2 = six.u('2.4.2')
22 VERSION_2_4_3 = six.u('2.4.3')
23 VERSION_2_4_4 = six.u('2.4.4')
24 VERSION_2_4_5 = six.u('2.4.5')
26 logger = logging.getLogger(__name__)
28 class Backend(object):
31 def filter_class(self):
32 """Returns the TaskFilter class used by this backend"""
36 def filter_tasks(self, filter_obj):
37 """Returns a list of Task objects matching the given filter"""
41 def save_task(self, task):
45 def delete_task(self, task):
49 def start_task(self, task):
53 def stop_task(self, task):
57 def complete_task(self, task):
61 def refresh_task(self, task, after_save=False):
63 Refreshes the given task. Returns new data dict with serialized
69 def annotate_task(self, task, annotation):
73 def denotate_task(self, task, annotation):
78 """Syncs the backend database with the taskd server"""
81 def convert_datetime_string(self, value):
83 Converts TW syntax datetime string to a localized datetime
84 object. This method is not mandatory.
89 class TaskWarriorException(Exception):
93 class TaskWarrior(object):
94 def __init__(self, data_location=None, create=True, taskrc_location='~/.taskrc'):
95 self.taskrc_location = os.path.expanduser(taskrc_location)
97 # If taskrc does not exist, pass / to use defaults and avoid creating
98 # dummy .taskrc file by TaskWarrior
99 if not os.path.exists(self.taskrc_location):
100 self.taskrc_location = '/'
102 self.version = self._get_version()
104 'confirmation': 'no',
105 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
106 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
108 # Defaults to on since 2.4.5, we expect off during parsing
111 # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
112 # arbitrary big number which is likely to be large enough
113 'bulk': 0 if self.version >= VERSION_2_4_3 else 100000,
116 # Set data.location override if passed via kwarg
117 if data_location is not None:
118 data_location = os.path.expanduser(data_location)
119 if create and not os.path.exists(data_location):
120 os.makedirs(data_location)
121 self.config['data.location'] = data_location
123 self.tasks = TaskQuerySet(self)
125 def _get_command_args(self, args, config_override=None):
126 command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
127 config = self.config.copy()
128 config.update(config_override or dict())
129 for item in config.items():
130 command_args.append('rc.{0}={1}'.format(*item))
131 command_args.extend(map(six.text_type, args))
134 def _get_version(self):
135 p = subprocess.Popen(
136 ['task', '--version'],
137 stdout=subprocess.PIPE,
138 stderr=subprocess.PIPE)
139 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
140 return stdout.strip('\n')
142 def _get_modified_task_fields_as_args(self, task):
145 def add_field(field):
146 # Add the output of format_field method to args list (defaults to
148 serialized_value = task._serialize(field, task._data[field])
150 # Empty values should not be enclosed in quotation marks, see
152 if serialized_value is '':
153 escaped_serialized_value = ''
155 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
157 format_default = lambda: six.u("{0}:{1}").format(field,
158 escaped_serialized_value)
160 format_func = getattr(self, 'format_{0}'.format(field),
163 args.append(format_func(task))
165 # If we're modifying saved task, simply pass on all modified fields
167 for field in task._modified_fields:
169 # For new tasks, pass all fields that make sense
171 for field in task._data.keys():
172 if field in task.read_only_fields:
178 def format_depends(self, task):
179 # We need to generate added and removed dependencies list,
180 # since Taskwarrior does not accept redefining dependencies.
182 # This cannot be part of serialize_depends, since we need
183 # to keep a list of all depedencies in the _data dictionary,
184 # not just currently added/removed ones
186 old_dependencies = task._original_data.get('depends', set())
188 added = self['depends'] - old_dependencies
189 removed = old_dependencies - self['depends']
191 # Removed dependencies need to be prefixed with '-'
192 return 'depends:' + ','.join(
193 [t['uuid'] for t in added] +
194 ['-' + t['uuid'] for t in removed]
197 def format_description(self, task):
198 # Task version older than 2.4.0 ignores first word of the
199 # task description if description: prefix is used
200 if self.version < VERSION_2_4_0:
201 return task._data['description']
203 return six.u("description:'{0}'").format(task._data['description'] or '')
205 def convert_datetime_string(self, value):
207 if self.version >= VERSION_2_4_0:
208 # For strings, use 'task calc' to evaluate the string to datetime
209 # available since TW 2.4.0
211 result = self.execute_command(['calc'] + args)
212 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
213 localized = local_zone.localize(naive)
215 raise ValueError("Provided value could not be converted to "
216 "datetime, its type is not supported: {}"
217 .format(type(value)))
220 def filter_class(self):
221 return TaskWarriorFilter
225 def get_config(self):
226 raw_output = self.execute_command(
228 config_override={'verbose': 'nothing'}
232 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
234 for line in raw_output:
235 match = config_regex.match(line)
237 config[match.group('key')] = match.group('value').strip()
241 def execute_command(self, args, config_override=None, allow_failure=True,
243 command_args = self._get_command_args(
244 args, config_override=config_override)
245 logger.debug(' '.join(command_args))
246 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
247 stderr=subprocess.PIPE)
248 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
249 if p.returncode and allow_failure:
251 error_msg = stderr.strip()
253 error_msg = stdout.strip()
254 raise TaskWarriorException(error_msg)
256 # Return all whole triplet only if explicitly asked for
258 return stdout.rstrip().split('\n')
260 return (stdout.rstrip().split('\n'),
261 stderr.rstrip().split('\n'),
264 def enforce_recurrence(self):
265 # Run arbitrary report command which will trigger generation
266 # of recurrent tasks.
268 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
269 if self.version < VERSION_2_4_2:
270 self.execute_command(['next'], allow_failure=False)
272 def merge_with(self, path, push=False):
273 path = path.rstrip('/') + '/'
274 self.execute_command(['merge', path], config_override={
275 'merge.autopush': 'yes' if push else 'no',
279 self.execute_command(['undo'])
281 # Backend interface implementation
283 def filter_tasks(self, filter_obj):
284 self.enforce_recurrence()
285 args = ['export', '--'] + filter_obj.get_filter_params()
287 for line in self.execute_command(args):
289 data = line.strip(',')
291 filtered_task = Task(self)
292 filtered_task._load_data(json.loads(data))
293 tasks.append(filtered_task)
295 raise TaskWarriorException('Invalid JSON: %s' % data)
298 def save_task(self, task):
299 """Save a task into TaskWarrior database using add/modify call"""
301 args = [task['uuid'], 'modify'] if task.saved else ['add']
302 args.extend(self._get_modified_task_fields_as_args(task))
303 output = self.execute_command(args)
305 # Parse out the new ID, if the task is being added for the first time
307 id_lines = [l for l in output if l.startswith('Created task ')]
309 # Complain loudly if it seems that more tasks were created
311 if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
312 raise TaskWarriorException("Unexpected output when creating "
313 "task: %s" % '\n'.join(id_lines))
315 # Circumvent the ID storage, since ID is considered read-only
316 identifier = id_lines[0].split(' ')[2].rstrip('.')
318 # Identifier can be either ID or UUID for completed tasks
320 task._data['id'] = int(identifier)
322 task._data['uuid'] = identifier
324 # Refreshing is very important here, as not only modification time
325 # is updated, but arbitrary attribute may have changed due hooks
326 # altering the data before saving
327 task.refresh(after_save=True)
329 def delete_task(self, task):
330 self.execute_command([task['uuid'], 'delete'])
332 def start_task(self, task):
333 self.execute_command([task['uuid'], 'start'])
335 def stop_task(self, task):
336 self.execute_command([task['uuid'], 'stop'])
338 def complete_task(self, task):
339 # Older versions of TW do not stop active task at completion
340 if self.version < VERSION_2_4_0 and task.active:
343 self.execute_command([task['uuid'], 'done'])
345 def annotate_task(self, task, annotation):
346 args = [task['uuid'], 'annotate', annotation]
347 self.execute_command(args)
349 def denotate_task(self, task, annotation):
350 args = [task['uuid'], 'denotate', annotation]
351 self.execute_command(args)
353 def refresh_task(self, task, after_save=False):
354 # We need to use ID as backup for uuid here for the refreshes
355 # of newly saved tasks. Any other place in the code is fine
356 # with using UUID only.
357 args = [task['uuid'] or task['id'], 'export']
358 output = self.execute_command(args)
361 return len(output) == 1 and output[0].startswith('{')
363 # For older TW versions attempt to uniquely locate the task
364 # using the data we have if it has been just saved.
365 # This can happen when adding a completed task on older TW versions.
366 if (not valid(output) and self.version < VERSION_2_4_5
369 # Make a copy, removing ID and UUID. It's most likely invalid
370 # (ID 0) if it failed to match a unique task.
371 data = copy.deepcopy(task._data)
373 data.pop('uuid', None)
375 taskfilter = self.filter_class(self)
376 for key, value in data.items():
377 taskfilter.add_filter_param(key, value)
379 output = self.execute_command(['export', '--'] +
380 taskfilter.get_filter_params())
382 # If more than 1 task has been matched still, raise an exception
383 if not valid(output):
384 raise TaskWarriorException(
385 "Unique identifiers {0} with description: {1} matches "
386 "multiple tasks: {2}".format(
387 task['uuid'] or task['id'], task['description'], output)
390 return json.loads(output[0])
393 self.execute_command(['sync'])