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

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