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,
98 taskrc_location=None, task_command='task',
99 version_override=None):
100 self.taskrc_location = None
102 self.taskrc_location = os.path.expanduser(taskrc_location)
104 # If taskrc does not exist, pass / to use defaults and avoid creating
105 # dummy .taskrc file by TaskWarrior
106 if not os.path.exists(self.taskrc_location):
107 self.taskrc_location = '/'
109 self.task_command = task_command
112 self.version = version_override or self._get_version()
114 'confirmation': 'no',
115 'dependency.confirmation': 'no', # See TW-1483 or taskrc man page
116 'recurrence.confirmation': 'no', # Necessary for modifying R tasks
118 # Defaults to on since 2.4.5, we expect off during parsing
121 # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
122 # arbitrary big number which is likely to be large enough
123 'bulk': 0 if self.version >= self.VERSION_2_4_3 else 100000,
126 # Set data.location override if passed via kwarg
127 if data_location is not None:
128 data_location = os.path.expanduser(data_location)
129 if create and not os.path.exists(data_location):
130 os.makedirs(data_location)
131 self.overrides['data.location'] = data_location
133 self.tasks = TaskQuerySet(self)
135 def _get_task_command(self):
136 return self.task_command.split()
138 def _get_command_args(self, args, config_override=None):
139 command_args = self._get_task_command()
140 overrides = self.overrides.copy()
141 overrides.update(config_override or dict())
142 for item in overrides.items():
143 command_args.append('rc.{0}={1}'.format(*item))
144 command_args.extend([
145 x.decode('utf-8') if isinstance(x, six.binary_type)
146 else six.text_type(x) for x in args
150 def _get_version(self):
151 p = subprocess.Popen(
152 self._get_task_command() + ['--version'],
153 stdout=subprocess.PIPE,
154 stderr=subprocess.PIPE)
155 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
156 return stdout.strip('\n')
158 def _get_modified_task_fields_as_args(self, task):
161 def add_field(field):
162 # Add the output of format_field method to args list (defaults to
164 serialized_value = task._serialize(field, task._data[field])
166 # Empty values should not be enclosed in quotation marks, see
168 if serialized_value is '':
169 escaped_serialized_value = ''
171 escaped_serialized_value = six.u("'{0}'").format(
174 format_default = lambda task: six.u("{0}:{1}").format(
175 field, escaped_serialized_value)
177 format_func = getattr(self, 'format_{0}'.format(field),
180 args.append(format_func(task))
182 # If we're modifying saved task, simply pass on all modified fields
184 for field in task._modified_fields:
187 # For new tasks, pass all fields that make sense
189 for field in task._data.keys():
190 # We cannot set stuff that's read only (ID, UUID, ..)
191 if field in task.read_only_fields:
193 # We do not want to do field deletion for new tasks
194 if task._data[field] is None:
196 # Otherwise we're fine
201 def format_depends(self, task):
202 # We need to generate added and removed dependencies list,
203 # since Taskwarrior does not accept redefining dependencies.
205 # This cannot be part of serialize_depends, since we need
206 # to keep a list of all depedencies in the _data dictionary,
207 # not just currently added/removed ones
209 old_dependencies = task._original_data.get('depends', set())
211 added = task['depends'] - old_dependencies
212 removed = old_dependencies - task['depends']
214 # Removed dependencies need to be prefixed with '-'
215 return 'depends:' + ','.join(
216 [t['uuid'] for t in added] +
217 ['-' + t['uuid'] for t in removed]
220 def format_description(self, task):
221 # Task version older than 2.4.0 ignores first word of the
222 # task description if description: prefix is used
223 if self.version < self.VERSION_2_4_0:
224 return task._data['description']
226 return six.u("description:'{0}'").format(
227 task._data['description'] or '',
230 def convert_datetime_string(self, value):
232 if self.version >= self.VERSION_2_4_0:
233 # For strings, use 'calc' to evaluate the string to datetime
234 # available since TW 2.4.0
236 result = self.execute_command(['calc'] + args)
237 naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
238 localized = local_zone.localize(naive)
241 'Provided value could not be converted to '
242 'datetime, its type is not supported: {}'
243 .format(type(value)),
249 def filter_class(self):
250 return TaskWarriorFilter
256 # First, check if memoized information is available
260 # If not, fetch the config using the 'show' command
261 raw_output = self.execute_command(
263 config_override={'verbose': 'nothing'}
267 config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].*$)')
269 for line in raw_output:
270 match = config_regex.match(line)
272 config[match.group('key')] = match.group('value').strip()
274 # Memoize the config dict
275 self._config = ReadOnlyDictView(config)
279 def execute_command(self, args, config_override=None, allow_failure=True,
281 command_args = self._get_command_args(
282 args, config_override=config_override)
283 logger.debug(u' '.join(command_args))
285 env = os.environ.copy()
286 if self.taskrc_location:
287 env['TASKRC'] = self.taskrc_location
288 p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
289 stderr=subprocess.PIPE, env=env)
290 stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
291 if p.returncode and allow_failure:
293 error_msg = stderr.strip()
295 error_msg = stdout.strip()
296 error_msg += u'\nCommand used: ' + u' '.join(command_args)
297 raise TaskWarriorException(error_msg)
299 # Return all whole triplet only if explicitly asked for
301 return stdout.rstrip().split('\n')
303 return (stdout.rstrip().split('\n'),
304 stderr.rstrip().split('\n'),
307 def enforce_recurrence(self):
308 # Run arbitrary report command which will trigger generation
309 # of recurrent tasks.
311 # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
312 if self.version < self.VERSION_2_4_2:
313 self.execute_command(['next'], allow_failure=False)
315 def merge_with(self, path, push=False):
316 path = path.rstrip('/') + '/'
317 self.execute_command(['merge', path], config_override={
318 'merge.autopush': 'yes' if push else 'no',
322 self.execute_command(['undo'])
324 # Backend interface implementation
326 def filter_tasks(self, filter_obj):
327 self.enforce_recurrence()
328 args = ['export'] + filter_obj.get_filter_params()
330 for line in self.execute_command(args):
332 data = line.strip(',')
334 filtered_task = Task(self)
335 filtered_task._load_data(json.loads(data))
336 tasks.append(filtered_task)
338 raise TaskWarriorException('Invalid JSON: %s' % data)
341 def save_task(self, task):
342 """Save a task into TaskWarrior database using add/modify call"""
344 args = [task['uuid'], 'modify'] if task.saved else ['add']
345 args.extend(self._get_modified_task_fields_as_args(task))
346 output = self.execute_command(args)
348 # Parse out the new ID, if the task is being added for the first time
350 id_lines = [l for l in output if l.startswith('Created task ')]
352 # Complain loudly if it seems that more tasks were created
354 # Expected output: Created task 1.
355 # Created task 1 (recurrence template).
356 if len(id_lines) != 1 or len(id_lines[0].split(' ')) not in (3, 5):
357 raise TaskWarriorException(
358 'Unexpected output when creating '
359 'task: %s' % '\n'.join(id_lines),
362 # Circumvent the ID storage, since ID is considered read-only
363 identifier = id_lines[0].split(' ')[2].rstrip('.')
365 # Identifier can be either ID or UUID for completed tasks
367 task._data['id'] = int(identifier)
369 task._data['uuid'] = identifier
371 # Refreshing is very important here, as not only modification time
372 # is updated, but arbitrary attribute may have changed due hooks
373 # altering the data before saving
374 task.refresh(after_save=True)
376 def delete_task(self, task):
377 self.execute_command([task['uuid'], 'delete'])
379 def start_task(self, task):
380 self.execute_command([task['uuid'], 'start'])
382 def stop_task(self, task):
383 self.execute_command([task['uuid'], 'stop'])
385 def complete_task(self, task):
386 # Older versions of TW do not stop active task at completion
387 if self.version < self.VERSION_2_4_0 and task.active:
390 self.execute_command([task['uuid'], 'done'])
392 def annotate_task(self, task, annotation):
393 args = [task['uuid'], 'annotate', annotation]
394 self.execute_command(args)
396 def denotate_task(self, task, annotation):
397 args = [task['uuid'], 'denotate', annotation]
398 self.execute_command(args)
400 def refresh_task(self, task, after_save=False):
401 # We need to use ID as backup for uuid here for the refreshes
402 # of newly saved tasks. Any other place in the code is fine
403 # with using UUID only.
404 args = [task['uuid'] or task['id'], 'export']
405 output = self.execute_command(args)
408 return len(output) == 1 and output[0].startswith('{')
410 # For older TW versions attempt to uniquely locate the task
411 # using the data we have if it has been just saved.
412 # This can happen when adding a completed task on older TW versions.
413 if (not valid(output) and self.version < self.VERSION_2_4_5
416 # Make a copy, removing ID and UUID. It's most likely invalid
417 # (ID 0) if it failed to match a unique task.
418 data = copy.deepcopy(task._data)
420 data.pop('uuid', None)
422 taskfilter = self.filter_class(self)
423 for key, value in data.items():
424 taskfilter.add_filter_param(key, value)
426 output = self.execute_command(['export'] +
427 taskfilter.get_filter_params())
429 # If more than 1 task has been matched still, raise an exception
430 if not valid(output):
431 raise TaskWarriorException(
432 'Unique identifiers {0} with description: {1} matches '
433 'multiple tasks: {2}'.format(
434 task['uuid'] or task['id'], task['description'], output)
437 return json.loads(output[0])
440 self.execute_command(['sync'])