]> git.madduck.net Git - etc/taskwarrior.git/blob - tasklib/backends.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

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.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

edf9ac3eeca32526b95ae3dc8e837dc87fd9c830
[etc/taskwarrior.git] / tasklib / backends.py
1 import abc
2 import datetime
3 import json
4 import os
5 import re
6 import six
7 import subprocess
8
9 from .task import Task, TaskQuerySet
10 from .filters import TaskWarriorFilter
11 from .serializing import local_zone
12
13 DATE_FORMAT_CALC = '%Y-%m-%dT%H:%M:%S'
14
15 VERSION_2_1_0 = six.u('2.1.0')
16 VERSION_2_2_0 = six.u('2.2.0')
17 VERSION_2_3_0 = six.u('2.3.0')
18 VERSION_2_4_0 = six.u('2.4.0')
19 VERSION_2_4_1 = six.u('2.4.1')
20 VERSION_2_4_2 = six.u('2.4.2')
21 VERSION_2_4_3 = six.u('2.4.3')
22 VERSION_2_4_4 = six.u('2.4.4')
23 VERSION_2_4_5 = six.u('2.4.5')
24
25
26 class Backend(object):
27
28     @abc.abstractproperty
29     def filter_class(self):
30         """Returns the TaskFilter class used by this backend"""
31         pass
32
33     @abc.abstractmethod
34     def filter_tasks(self, filter_obj):
35         """Returns a list of Task objects matching the given filter"""
36         pass
37
38     @abc.abstractmethod
39     def save_task(self, task):
40         pass
41
42     @abc.abstractmethod
43     def delete_task(self, task):
44         pass
45
46     @abc.abstractmethod
47     def start_task(self, task):
48         pass
49
50     @abc.abstractmethod
51     def stop_task(self, task):
52         pass
53
54     @abc.abstractmethod
55     def complete_task(self, task):
56         pass
57
58     @abc.abstractmethod
59     def refresh_task(self, task, after_save=False):
60         """
61         Refreshes the given task. Returns new data dict with serialized
62         attributes.
63         """
64         pass
65
66     @abc.abstractmethod
67     def annotate_task(self, task, annotation):
68         pass
69
70     @abc.abstractmethod
71     def denotate_task(self, task, annotation):
72         pass
73
74     @abc.abstractmethod
75     def sync(self):
76         """Syncs the backend database with the taskd server"""
77         pass
78
79     def convert_datetime_string(self, value):
80         """
81         Converts TW syntax datetime string to a localized datetime
82         object. This method is not mandatory.
83         """
84         raise NotImplemented
85
86
87 class TaskWarriorException(Exception):
88     pass
89
90
91 class TaskWarrior(object):
92     def __init__(self, data_location=None, create=True, taskrc_location='~/.taskrc'):
93         self.taskrc_location = os.path.expanduser(taskrc_location)
94
95         # If taskrc does not exist, pass / to use defaults and avoid creating
96         # dummy .taskrc file by TaskWarrior
97         if not os.path.exists(self.taskrc_location):
98             self.taskrc_location = '/'
99
100         self.version = self._get_version()
101         self.config = {
102             'confirmation': 'no',
103             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
104             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
105
106             # Defaults to on since 2.4.5, we expect off during parsing
107             'json.array': 'off',
108
109             # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
110             # arbitrary big number which is likely to be large enough
111             'bulk': 0 if self.version >= VERSION_2_4_3 else 100000,
112         }
113
114         # Set data.location override if passed via kwarg
115         if data_location is not None:
116             data_location = os.path.expanduser(data_location)
117             if create and not os.path.exists(data_location):
118                 os.makedirs(data_location)
119             self.config['data.location'] = data_location
120
121         self.tasks = TaskQuerySet(self)
122
123     def _get_command_args(self, args, config_override=None):
124         command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
125         config = self.config.copy()
126         config.update(config_override or dict())
127         for item in config.items():
128             command_args.append('rc.{0}={1}'.format(*item))
129         command_args.extend(map(six.text_type, args))
130         return command_args
131
132     def _get_version(self):
133         p = subprocess.Popen(
134                 ['task', '--version'],
135                 stdout=subprocess.PIPE,
136                 stderr=subprocess.PIPE)
137         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
138         return stdout.strip('\n')
139
140     def _get_modified_task_fields_as_args(self, task):
141         args = []
142
143         def add_field(field):
144             # Add the output of format_field method to args list (defaults to
145             # field:value)
146             serialized_value = task._serialize(field, task._data[field])
147
148             # Empty values should not be enclosed in quotation marks, see
149             # TW-1510
150             if serialized_value is '':
151                 escaped_serialized_value = ''
152             else:
153                 escaped_serialized_value = six.u("'{0}'").format(serialized_value)
154
155             format_default = lambda: six.u("{0}:{1}").format(field,
156                                                       escaped_serialized_value)
157
158             format_func = getattr(self, 'format_{0}'.format(field),
159                                   format_default)
160
161             args.append(format_func(task))
162
163         # If we're modifying saved task, simply pass on all modified fields
164         if task.saved:
165             for field in task._modified_fields:
166                 add_field(field)
167         # For new tasks, pass all fields that make sense
168         else:
169             for field in task._data.keys():
170                 if field in task.read_only_fields:
171                     continue
172                 add_field(field)
173
174         return args
175
176     def format_depends(self, task):
177         # We need to generate added and removed dependencies list,
178         # since Taskwarrior does not accept redefining dependencies.
179
180         # This cannot be part of serialize_depends, since we need
181         # to keep a list of all depedencies in the _data dictionary,
182         # not just currently added/removed ones
183
184         old_dependencies = task._original_data.get('depends', set())
185
186         added = self['depends'] - old_dependencies
187         removed = old_dependencies - self['depends']
188
189         # Removed dependencies need to be prefixed with '-'
190         return 'depends:' + ','.join(
191                 [t['uuid'] for t in added] +
192                 ['-' + t['uuid'] for t in removed]
193             )
194
195     def format_description(self, task):
196         # Task version older than 2.4.0 ignores first word of the
197         # task description if description: prefix is used
198         if self.version < VERSION_2_4_0:
199             return task._data['description']
200         else:
201             return six.u("description:'{0}'").format(task._data['description'] or '')
202
203     def convert_datetime_string(self, value):
204
205         if self.version >= VERSION_2_4_0:
206             # For strings, use 'task calc' to evaluate the string to datetime
207             # available since TW 2.4.0
208             args = value.split()
209             result = self.execute_command(['calc'] + args)
210             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
211             localized = local_zone.localize(naive)
212         else:
213             raise ValueError("Provided value could not be converted to "
214                              "datetime, its type is not supported: {}"
215                              .format(type(value)))
216
217     @property
218     def filter_class(self):
219         return TaskWarriorFilter
220
221     # Public interface
222
223     def get_config(self):
224         raw_output = self.execute_command(
225                 ['show'],
226                 config_override={'verbose': 'nothing'}
227             )
228
229         config = dict()
230         config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
231
232         for line in raw_output:
233             match = config_regex.match(line)
234             if match:
235                 config[match.group('key')] = match.group('value').strip()
236
237         return config
238
239     def execute_command(self, args, config_override=None, allow_failure=True,
240                         return_all=False):
241         command_args = self._get_command_args(
242             args, config_override=config_override)
243         logger.debug(' '.join(command_args))
244         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
245                              stderr=subprocess.PIPE)
246         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
247         if p.returncode and allow_failure:
248             if stderr.strip():
249                 error_msg = stderr.strip()
250             else:
251                 error_msg = stdout.strip()
252             raise TaskWarriorException(error_msg)
253
254         # Return all whole triplet only if explicitly asked for
255         if not return_all:
256             return stdout.rstrip().split('\n')
257         else:
258             return (stdout.rstrip().split('\n'),
259                     stderr.rstrip().split('\n'),
260                     p.returncode)
261
262     def enforce_recurrence(self):
263         # Run arbitrary report command which will trigger generation
264         # of recurrent tasks.
265
266         # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
267         if self.version < VERSION_2_4_2:
268             self.execute_command(['next'], allow_failure=False)
269
270     def merge_with(self, path, push=False):
271         path = path.rstrip('/') + '/'
272         self.execute_command(['merge', path], config_override={
273             'merge.autopush': 'yes' if push else 'no',
274         })
275
276     def undo(self):
277         self.execute_command(['undo'])
278
279     # Backend interface implementation
280
281     def filter_tasks(self, filter_obj):
282         self.enforce_recurrence()
283         args = ['export', '--'] + filter_obj.get_filter_params()
284         tasks = []
285         for line in self.execute_command(args):
286             if line:
287                 data = line.strip(',')
288                 try:
289                     filtered_task = Task(self)
290                     filtered_task._load_data(json.loads(data))
291                     tasks.append(filtered_task)
292                 except ValueError:
293                     raise TaskWarriorException('Invalid JSON: %s' % data)
294         return tasks
295
296     def save_task(self, task):
297         """Save a task into TaskWarrior database using add/modify call"""
298
299         args = [task['uuid'], 'modify'] if task.saved else ['add']
300         args.extend(self._get_modified_task_fields_as_args(task))
301         output = self.execute_command(args)
302
303         # Parse out the new ID, if the task is being added for the first time
304         if not task.saved:
305             id_lines = [l for l in output if l.startswith('Created task ')]
306
307             # Complain loudly if it seems that more tasks were created
308             # Should not happen
309             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
310                 raise TaskWarriorException("Unexpected output when creating "
311                                            "task: %s" % '\n'.join(id_lines))
312
313             # Circumvent the ID storage, since ID is considered read-only
314             identifier = id_lines[0].split(' ')[2].rstrip('.')
315
316             # Identifier can be either ID or UUID for completed tasks
317             try:
318                 task._data['id'] = int(identifier)
319             except ValueError:
320                 task._data['uuid'] = identifier
321
322         # Refreshing is very important here, as not only modification time
323         # is updated, but arbitrary attribute may have changed due hooks
324         # altering the data before saving
325         task.refresh(after_save=True)
326
327     def delete_task(self, task):
328         self.execute_command([task['uuid'], 'delete'])
329
330     def start_task(self, task):
331         self.execute_command([task['uuid'], 'start'])
332
333     def stop_task(self, task):
334         self.execute_command([task['uuid'], 'stop'])
335
336     def complete_task(self, task):
337         # Older versions of TW do not stop active task at completion
338         if self.version < VERSION_2_4_0 and task.active:
339             task.stop()
340
341         self.execute_command([task['uuid'], 'done'])
342
343     def annotate_task(self, task, annotation):
344         args = [task['uuid'], 'annotate', annotation]
345         self.execute_command(args)
346
347     def denotate_task(self, task, annotation):
348         args = [task['uuid'], 'denotate', annotation]
349         self.execute_command(args)
350
351     def refresh_task(self, task, after_save=False):
352         # We need to use ID as backup for uuid here for the refreshes
353         # of newly saved tasks. Any other place in the code is fine
354         # with using UUID only.
355         args = [task['uuid'] or task['id'], 'export']
356         output = self.execute_command(args)
357
358         def valid(output):
359             return len(output) == 1 and output[0].startswith('{')
360
361         # For older TW versions attempt to uniquely locate the task
362         # using the data we have if it has been just saved.
363         # This can happen when adding a completed task on older TW versions.
364         if (not valid(output) and self.version < VERSION_2_4_5
365                 and after_save):
366
367             # Make a copy, removing ID and UUID. It's most likely invalid
368             # (ID 0) if it failed to match a unique task.
369             data = copy.deepcopy(task._data)
370             data.pop('id', None)
371             data.pop('uuid', None)
372
373             taskfilter = self.filter_class(self)
374             for key, value in data.items():
375                 taskfilter.add_filter_param(key, value)
376
377             output = self.execute_command(['export', '--'] +
378                 taskfilter.get_filter_params())
379
380         # If more than 1 task has been matched still, raise an exception
381         if not valid(output):
382             raise TaskWarriorException(
383                 "Unique identifiers {0} with description: {1} matches "
384                 "multiple tasks: {2}".format(
385                 task['uuid'] or task['id'], task['description'], output)
386             )
387
388         return json.loads(output[0])
389
390     def sync(self):
391         self.execute_command(['sync'])