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

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