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

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