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

Bump version for 1.2.0 release
[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,
98                  taskrc_location=None, task_command='task',
99                  version_override=None):
100         self.taskrc_location = None
101         if taskrc_location:
102             self.taskrc_location = os.path.expanduser(taskrc_location)
103
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 = '/'
108
109         self.task_command = task_command
110
111         self._config = None
112         self.version = version_override or self._get_version()
113         self.overrides = {
114             'confirmation': 'no',
115             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
116             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
117
118             # Defaults to on since 2.4.5, we expect off during parsing
119             'json.array': 'off',
120
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,
124         }
125
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
132
133         self.tasks = TaskQuerySet(self)
134
135     def _get_task_command(self):
136         return self.task_command.split()
137
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
147         ])
148         return command_args
149
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')
157
158     def _get_modified_task_fields_as_args(self, task):
159         args = []
160
161         def add_field(field):
162             # Add the output of format_field method to args list (defaults to
163             # field:value)
164             serialized_value = task._serialize(field, task._data[field])
165
166             # Empty values should not be enclosed in quotation marks, see
167             # TW-1510
168             if serialized_value is '':
169                 escaped_serialized_value = ''
170             else:
171                 escaped_serialized_value = six.u("'{0}'").format(
172                     serialized_value)
173
174             format_default = lambda task: six.u("{0}:{1}").format(
175                 field, escaped_serialized_value)
176
177             format_func = getattr(self, 'format_{0}'.format(field),
178                                   format_default)
179
180             args.append(format_func(task))
181
182         # If we're modifying saved task, simply pass on all modified fields
183         if task.saved:
184             for field in task._modified_fields:
185                 add_field(field)
186
187         # For new tasks, pass all fields that make sense
188         else:
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:
192                     continue
193                 # We do not want to do field deletion for new tasks
194                 if task._data[field] is None:
195                     continue
196                 # Otherwise we're fine
197                 add_field(field)
198
199         return args
200
201     def format_depends(self, task):
202         # We need to generate added and removed dependencies list,
203         # since Taskwarrior does not accept redefining dependencies.
204
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
208
209         old_dependencies = task._original_data.get('depends', set())
210
211         added = task['depends'] - old_dependencies
212         removed = old_dependencies - task['depends']
213
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]
218         )
219
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']
225         else:
226             return six.u("description:'{0}'").format(
227                 task._data['description'] or '',
228             )
229
230     def convert_datetime_string(self, value):
231
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
235             args = value.split()
236             result = self.execute_command(['calc'] + args)
237             naive = datetime.datetime.strptime(result[0], DATE_FORMAT_CALC)
238             localized = local_zone.localize(naive)
239         else:
240             raise ValueError(
241                 'Provided value could not be converted to '
242                 'datetime, its type is not supported: {}'
243                 .format(type(value)),
244             )
245
246         return localized
247
248     @property
249     def filter_class(self):
250         return TaskWarriorFilter
251
252     # Public interface
253
254     @property
255     def config(self):
256         # First, check if memoized information is available
257         if self._config:
258             return self._config
259
260         # If not, fetch the config using the 'show' command
261         raw_output = self.execute_command(
262             ['show'],
263             config_override={'verbose': 'nothing'}
264         )
265
266         config = dict()
267         config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].*$)')
268
269         for line in raw_output:
270             match = config_regex.match(line)
271             if match:
272                 config[match.group('key')] = match.group('value').strip()
273
274         # Memoize the config dict
275         self._config = ReadOnlyDictView(config)
276
277         return self._config
278
279     def execute_command(self, args, config_override=None, allow_failure=True,
280                         return_all=False):
281         command_args = self._get_command_args(
282             args, config_override=config_override)
283         logger.debug(u' '.join(command_args))
284
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:
292             if stderr.strip():
293                 error_msg = stderr.strip()
294             else:
295                 error_msg = stdout.strip()
296             error_msg += u'\nCommand used: ' + u' '.join(command_args)
297             raise TaskWarriorException(error_msg)
298
299         # Return all whole triplet only if explicitly asked for
300         if not return_all:
301             return stdout.rstrip().split('\n')
302         else:
303             return (stdout.rstrip().split('\n'),
304                     stderr.rstrip().split('\n'),
305                     p.returncode)
306
307     def enforce_recurrence(self):
308         # Run arbitrary report command which will trigger generation
309         # of recurrent tasks.
310
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)
314
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',
319         })
320
321     def undo(self):
322         self.execute_command(['undo'])
323
324     # Backend interface implementation
325
326     def filter_tasks(self, filter_obj):
327         self.enforce_recurrence()
328         args = ['export'] + filter_obj.get_filter_params()
329         tasks = []
330         for line in self.execute_command(args):
331             if line:
332                 data = line.strip(',')
333                 try:
334                     filtered_task = Task(self)
335                     filtered_task._load_data(json.loads(data))
336                     tasks.append(filtered_task)
337                 except ValueError:
338                     raise TaskWarriorException('Invalid JSON: %s' % data)
339         return tasks
340
341     def save_task(self, task):
342         """Save a task into TaskWarrior database using add/modify call"""
343
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)
347
348         # Parse out the new ID, if the task is being added for the first time
349         if not task.saved:
350             id_lines = [l for l in output if l.startswith('Created task ')]
351
352             # Complain loudly if it seems that more tasks were created
353             # Should not happen.
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),
360                 )
361
362             # Circumvent the ID storage, since ID is considered read-only
363             identifier = id_lines[0].split(' ')[2].rstrip('.')
364
365             # Identifier can be either ID or UUID for completed tasks
366             try:
367                 task._data['id'] = int(identifier)
368             except ValueError:
369                 task._data['uuid'] = identifier
370
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)
375
376     def delete_task(self, task):
377         self.execute_command([task['uuid'], 'delete'])
378
379     def start_task(self, task):
380         self.execute_command([task['uuid'], 'start'])
381
382     def stop_task(self, task):
383         self.execute_command([task['uuid'], 'stop'])
384
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:
388             task.stop()
389
390         self.execute_command([task['uuid'], 'done'])
391
392     def annotate_task(self, task, annotation):
393         args = [task['uuid'], 'annotate', annotation]
394         self.execute_command(args)
395
396     def denotate_task(self, task, annotation):
397         args = [task['uuid'], 'denotate', annotation]
398         self.execute_command(args)
399
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)
406
407         def valid(output):
408             return len(output) == 1 and output[0].startswith('{')
409
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
414                 and after_save):
415
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)
419             data.pop('id', None)
420             data.pop('uuid', None)
421
422             taskfilter = self.filter_class(self)
423             for key, value in data.items():
424                 taskfilter.add_filter_param(key, value)
425
426             output = self.execute_command(['export'] +
427                                           taskfilter.get_filter_params())
428
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)
435             )
436
437         return json.loads(output[0])
438
439     def sync(self):
440         self.execute_command(['sync'])