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