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

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