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

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