]> 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:

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