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