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

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