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

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