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

TaskWarrior: Implement sync method
[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.task import TaskFilter
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 = TaskFilter
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 sync(self):
59         """Syncs the backend database with the taskd server"""
60         pass
61
62
63 class TaskWarriorException(Exception):
64     pass
65
66
67 class TaskWarrior(object):
68     def __init__(self, data_location=None, create=True, taskrc_location='~/.taskrc'):
69         self.taskrc_location = os.path.expanduser(taskrc_location)
70
71         # If taskrc does not exist, pass / to use defaults and avoid creating
72         # dummy .taskrc file by TaskWarrior
73         if not os.path.exists(self.taskrc_location):
74             self.taskrc_location = '/'
75
76         self.version = self._get_version()
77         self.config = {
78             'confirmation': 'no',
79             'dependency.confirmation': 'no',  # See TW-1483 or taskrc man page
80             'recurrence.confirmation': 'no',  # Necessary for modifying R tasks
81
82             # Defaults to on since 2.4.5, we expect off during parsing
83             'json.array': 'off',
84
85             # 2.4.3 onwards supports 0 as infite bulk, otherwise set just
86             # arbitrary big number which is likely to be large enough
87             'bulk': 0 if self.version >= VERSION_2_4_3 else 100000,
88         }
89
90         # Set data.location override if passed via kwarg
91         if data_location is not None:
92             data_location = os.path.expanduser(data_location)
93             if create and not os.path.exists(data_location):
94                 os.makedirs(data_location)
95             self.config['data.location'] = data_location
96
97         self.tasks = TaskQuerySet(self)
98
99     def _get_command_args(self, args, config_override=None):
100         command_args = ['task', 'rc:{0}'.format(self.taskrc_location)]
101         config = self.config.copy()
102         config.update(config_override or dict())
103         for item in config.items():
104             command_args.append('rc.{0}={1}'.format(*item))
105         command_args.extend(map(six.text_type, args))
106         return command_args
107
108     def _get_version(self):
109         p = subprocess.Popen(
110                 ['task', '--version'],
111                 stdout=subprocess.PIPE,
112                 stderr=subprocess.PIPE)
113         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
114         return stdout.strip('\n')
115
116     def get_config(self):
117         raw_output = self.execute_command(
118                 ['show'],
119                 config_override={'verbose': 'nothing'}
120             )
121
122         config = dict()
123         config_regex = re.compile(r'^(?P<key>[^\s]+)\s+(?P<value>[^\s].+$)')
124
125         for line in raw_output:
126             match = config_regex.match(line)
127             if match:
128                 config[match.group('key')] = match.group('value').strip()
129
130         return config
131
132     def execute_command(self, args, config_override=None, allow_failure=True,
133                         return_all=False):
134         command_args = self._get_command_args(
135             args, config_override=config_override)
136         logger.debug(' '.join(command_args))
137         p = subprocess.Popen(command_args, stdout=subprocess.PIPE,
138                              stderr=subprocess.PIPE)
139         stdout, stderr = [x.decode('utf-8') for x in p.communicate()]
140         if p.returncode and allow_failure:
141             if stderr.strip():
142                 error_msg = stderr.strip()
143             else:
144                 error_msg = stdout.strip()
145             raise TaskWarriorException(error_msg)
146
147         # Return all whole triplet only if explicitly asked for
148         if not return_all:
149             return stdout.rstrip().split('\n')
150         else:
151             return (stdout.rstrip().split('\n'),
152                     stderr.rstrip().split('\n'),
153                     p.returncode)
154
155     def enforce_recurrence(self):
156         # Run arbitrary report command which will trigger generation
157         # of recurrent tasks.
158
159         # Only necessary for TW up to 2.4.1, fixed in 2.4.2.
160         if self.version < VERSION_2_4_2:
161             self.execute_command(['next'], allow_failure=False)
162
163     def merge_with(self, path, push=False):
164         path = path.rstrip('/') + '/'
165         self.execute_command(['merge', path], config_override={
166             'merge.autopush': 'yes' if push else 'no',
167         })
168
169     def undo(self):
170         self.execute_command(['undo'])
171
172     # Backend interface implementation
173
174     def filter_tasks(self, filter_obj):
175         self.enforce_recurrence()
176         args = ['export', '--'] + filter_obj.get_filter_params()
177         tasks = []
178         for line in self.execute_command(args):
179             if line:
180                 data = line.strip(',')
181                 try:
182                     filtered_task = Task(self)
183                     filtered_task._load_data(json.loads(data))
184                     tasks.append(filtered_task)
185                 except ValueError:
186                     raise TaskWarriorException('Invalid JSON: %s' % data)
187         return tasks
188
189     def save_task(self, task):
190         """Save a task into TaskWarrior database using add/modify call"""
191
192         args = [task['uuid'], 'modify'] if task.saved else ['add']
193         args.extend(task._get_modified_fields_as_args())
194         output = self.execute_command(args)
195
196         # Parse out the new ID, if the task is being added for the first time
197         if not task.saved:
198             id_lines = [l for l in output if l.startswith('Created task ')]
199
200             # Complain loudly if it seems that more tasks were created
201             # Should not happen
202             if len(id_lines) != 1 or len(id_lines[0].split(' ')) != 3:
203                 raise TaskWarriorException("Unexpected output when creating "
204                                            "task: %s" % '\n'.join(id_lines))
205
206             # Circumvent the ID storage, since ID is considered read-only
207             identifier = id_lines[0].split(' ')[2].rstrip('.')
208
209             # Identifier can be either ID or UUID for completed tasks
210             try:
211                 task._data['id'] = int(identifier)
212             except ValueError:
213                 task._data['uuid'] = identifier
214
215         # Refreshing is very important here, as not only modification time
216         # is updated, but arbitrary attribute may have changed due hooks
217         # altering the data before saving
218         task.refresh(after_save=True)
219
220     def delete_task(self, task):
221         self.execute_command([task['uuid'], 'delete'])
222
223     def start_task(self, task):
224         self.execute_command([task['uuid'], 'start'])
225
226     def stop_task(self, task):
227         self.execute_command([task['uuid'], 'stop'])
228
229     def complete_task(self, task):
230         # Older versions of TW do not stop active task at completion
231         if self.version < VERSION_2_4_0 and task.active:
232             task.stop()
233
234         self.execute_command([task['uuid'], 'done'])
235
236     def refresh_task(self, task, after_save=False):
237         # We need to use ID as backup for uuid here for the refreshes
238         # of newly saved tasks. Any other place in the code is fine
239         # with using UUID only.
240         args = [task['uuid'] or task['id'], 'export']
241         output = self.execute_command(args)
242
243         def valid(output):
244             return len(output) == 1 and output[0].startswith('{')
245
246         # For older TW versions attempt to uniquely locate the task
247         # using the data we have if it has been just saved.
248         # This can happen when adding a completed task on older TW versions.
249         if (not valid(output) and self.version < VERSION_2_4_5
250                 and after_save):
251
252             # Make a copy, removing ID and UUID. It's most likely invalid
253             # (ID 0) if it failed to match a unique task.
254             data = copy.deepcopy(task._data)
255             data.pop('id', None)
256             data.pop('uuid', None)
257
258             taskfilter = self.filter_class(self)
259             for key, value in data.items():
260                 taskfilter.add_filter_param(key, value)
261
262             output = self.execute_command(['export', '--'] +
263                 taskfilter.get_filter_params())
264
265         # If more than 1 task has been matched still, raise an exception
266         if not valid(output):
267             raise TaskWarriorException(
268                 "Unique identifiers {0} with description: {1} matches "
269                 "multiple tasks: {2}".format(
270                 task['uuid'] or task['id'], task['description'], output)
271             )
272
273         return json.loads(output[0])
274
275     def sync(self):
276         self.execute_command(['sync'])