setup.py: make libbe._version optional.
[be.git] / libbe / command / subscribe.py
1 # Copyright (C) 2009-2012 Chris Ball <cjb@laptop.org>
2 #                         Gianluca Montecchi <gian@grys.it>
3 #                         W. Trevor King <wking@tremily.us>
4 #
5 # This file is part of Bugs Everywhere.
6 #
7 # Bugs Everywhere is free software: you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by the Free
9 # Software Foundation, either version 2 of the License, or (at your option) any
10 # later version.
11 #
12 # Bugs Everywhere is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
14 # FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
15 # more details.
16 #
17 # You should have received a copy of the GNU General Public License along with
18 # Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
19
20 import copy
21 import os
22
23 import libbe
24 import libbe.bug
25 import libbe.command
26 import libbe.diff
27 import libbe.command.util
28 import libbe.util.id
29 import libbe.util.tree
30
31
32 TAG="SUBSCRIBE:"
33
34
35 class Subscribe (libbe.command.Command):
36     """(Un)subscribe to change notification
37
38     >>> import sys
39     >>> import libbe.bugdir
40     >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
41     >>> io = libbe.command.StringInputOutput()
42     >>> io.stdout = sys.stdout
43     >>> ui = libbe.command.UserInterface(io=io)
44     >>> ui.storage_callbacks.set_storage(bd.storage)
45     >>> cmd = Subscribe(ui=ui)
46
47     >>> a = bd.bug_from_uuid('a')
48     >>> print a.extra_strings
49     []
50     >>> ret = ui.run(cmd, {'subscriber':'John Doe <j@doe.com>'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE
51     Subscriptions for abc/a:
52     John Doe <j@doe.com>    all    *
53     >>> bd.flush_reload()
54     >>> a = bd.bug_from_uuid('a')
55     >>> print a.extra_strings
56     ['SUBSCRIBE:John Doe <j@doe.com>\\tall\\t*']
57     >>> ret = ui.run(cmd, {'subscriber':'Jane Doe <J@doe.com>', 'servers':'a.com,b.net'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE
58     Subscriptions for abc/a:
59     Jane Doe <J@doe.com>    all    a.com,b.net
60     John Doe <j@doe.com>    all    *
61     >>> ret = ui.run(cmd, {'subscriber':'Jane Doe <J@doe.com>', 'servers':'a.edu'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE
62     Subscriptions for abc/a:
63     Jane Doe <J@doe.com>    all    a.com,a.edu,b.net
64     John Doe <j@doe.com>    all    *
65     >>> ret = ui.run(cmd, {'unsubscribe':True, 'subscriber':'Jane Doe <J@doe.com>', 'servers':'a.com'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE
66     Subscriptions for abc/a:
67     Jane Doe <J@doe.com>    all    a.edu,b.net
68     John Doe <j@doe.com>    all    *
69     >>> ret = ui.run(cmd, {'subscriber':'Jane Doe <J@doe.com>', 'servers':'*'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE
70     Subscriptions for abc/a:
71     Jane Doe <J@doe.com>    all    *
72     John Doe <j@doe.com>    all    *
73     >>> ret = ui.run(cmd, {'unsubscribe':True, 'subscriber':'Jane Doe <J@doe.com>'}, ['/a']) # doctest: +NORMALIZE_WHITESPACE
74     Subscriptions for abc/a:
75     John Doe <j@doe.com>    all    *
76     >>> ret = ui.run(cmd, {'unsubscribe':True, 'subscriber':'John Doe <j@doe.com>'}, ['/a'])
77     >>> ret = ui.run(cmd,
78     ...     {'subscriber':'Jane Doe <J@doe.com>', 'types':'new'},
79     ...     [bd.uuid[:3]]) # doctest: +NORMALIZE_WHITESPACE
80     Subscriptions for abc:
81     Jane Doe <J@doe.com>    new    *
82     >>> ret = ui.run(cmd,
83     ...     {'subscriber':'Jane Doe <J@doe.com>'},
84     ...     [bd.uuid]) # doctest: +NORMALIZE_WHITESPACE
85     Subscriptions for abc:
86     Jane Doe <J@doe.com>    all    *
87     >>> ui.cleanup()
88     >>> bd.cleanup()
89     """
90     name = 'subscribe'
91
92     def __init__(self, *args, **kwargs):
93         libbe.command.Command.__init__(self, *args, **kwargs)
94         self.options.extend([
95                 libbe.command.Option(name='unsubscribe', short_name='u',
96                     help='Unsubscribe instead of subscribing'),
97                 libbe.command.Option(name='list-all', short_name='a',
98                     help='List all subscribers (no ID argument, read only action)'),
99                 libbe.command.Option(name='list', short_name='l',
100                     help='List subscribers (read only action).'),
101                 libbe.command.Option(name='subscriber', short_name='s',
102                     help='Email address of the subscriber (defaults to your user id).',
103                     arg=libbe.command.Argument(
104                         name='subscriber', metavar='EMAIL')),
105                 libbe.command.Option(name='servers', short_name='S',
106                     help='Servers from which you want notification.',
107                     arg=libbe.command.Argument(
108                         name='servers', metavar='STRING')),
109                 libbe.command.Option(name='types', short_name='t',
110                     help='Types of changes you wish to be notified about.',
111                     arg=libbe.command.Argument(
112                         name='types', metavar='STRING')),
113                 ])
114         self.args.extend([
115                 libbe.command.Argument(
116                     name='id', metavar='ID', default=tuple(),
117                     optional=True, repeatable=True,
118                     completion_callback=libbe.command.util.complete_bug_comment_id),
119                 ])
120
121     def _run(self, **params):
122         storage = self._get_storage()
123         bugdirs = self._get_bugdirs()
124         if params['list-all'] == True or params['list'] == True:
125             writeable = storage.writeable
126             storage.writeable = False
127             if params['list-all'] == True:
128                 assert len(params['id']) == 0, params['id']
129         subscriber = params['subscriber']
130         if subscriber == None:
131             subscriber = self._get_user_id()
132         if params['unsubscribe'] == True:
133             if params['servers'] == None:
134                 params['servers'] = 'INVALID'
135             if params['types'] == None:
136                 params['types'] = 'INVALID'
137         else:
138             if params['servers'] == None:
139                 params['servers'] = '*'
140             if params['types'] == None:
141                 params['types'] = 'all'
142         servers = params['servers'].split(',')
143         types = params['types'].split(',')
144
145         if len(params['id']) == 0:
146             params['id'] = bugdirs.keys()
147         for _id in params['id']:
148             p = libbe.util.id.parse_user(bugdirs, _id)
149             if p['type'] == 'bugdir':
150                 type_root = libbe.diff.BUGDIR_TYPE_ALL
151                 entity = bugdirs[p['bugdir']]
152             else: # bug-specific subscriptions
153                 type_root = libbe.diff.BUG_TYPE_ALL
154                 bugdir,bug,comment = (
155                     libbe.command.util.bugdir_bug_comment_from_user_id(
156                         bugdirs, _id))
157                 entity = bug
158             entity_name = entity.id.user()
159             if params['list-all'] == True:
160                 entity_name = 'anything in the bug directory'
161             types = [libbe.diff.type_from_name(name, type_root, default=libbe.diff.INVALID_TYPE,
162                                          default_ok=params['unsubscribe'])
163                      for name in types]
164             estrs = entity.extra_strings
165             if params['list'] == True or params['list-all'] == True:
166                 pass
167             else: # alter subscriptions
168                 if params['unsubscribe'] == True:
169                     estrs = unsubscribe(estrs, subscriber, types, servers, type_root)
170                 else: # add the tag
171                     estrs = subscribe(estrs, subscriber, types, servers, type_root)
172                 entity.extra_strings = estrs # reassign to notice change
173
174             if params['list-all'] == True:
175                 subscriptions = []
176                 for bugdir in bugdirs.values():
177                     bugdir.load_all_bugs()
178                     subscriptions.extend(
179                         get_bugdir_subscribers(bugdir, servers[0]))
180             else:
181                 subscriptions = []
182                 for estr in entity.extra_strings:
183                     if estr.startswith(TAG):
184                         subscriptions.append(estr[len(TAG):])
185
186             if len(subscriptions) > 0:
187                 print >> self.stdout, 'Subscriptions for %s:' % entity_name
188                 print >> self.stdout, '\n'.join(subscriptions)
189         if params['list-all'] == True or params['list'] == True:
190             storage.writeable = writeable
191         return 0
192
193     def _long_help(self):
194         return """
195 ID can be either a bug ID, a bugdir ID, or blank, in which case it
196 refers to all known bugdirs.
197
198 SERVERS specifies the servers from which you would like to receive
199 notification.  Multiple severs may be specified in a comma-separated
200 list, or you can use "*" to match all servers (the default).  If you
201 have not selected a server, it should politely refrain from notifying
202 you of changes, although there is no way to guarantee this behavior.
203
204 Available TYPES:
205   For bugs:
206 %s
207   For %s:
208 %s
209
210 For unsubscription, any listed SERVERS and TYPES are removed from your
211 subscription.  Either the catch-all server "*" or type "%s" will
212 remove SUBSCRIBER entirely from the specified ID.
213
214 This command is intended for use primarily by public interfaces, since
215 if you're just hacking away on your private repository, you'll known
216 what's changed ;).  This command just (un)sets the appropriate
217 subscriptions, and leaves it up to each interface to perform the
218 notification.
219 """ % (libbe.diff.BUG_TYPE_ALL.string_tree(6), libbe.diff.BUGDIR_ID,
220        libbe.diff.BUGDIR_TYPE_ALL.string_tree(6),
221        libbe.diff.BUGDIR_TYPE_ALL)
222
223
224 # internal helper functions
225
226 def _generate_string(subscriber, types, servers):
227     types = sorted([str(t) for t in types])
228     servers = sorted(servers)
229     return "%s%s\t%s\t%s" % (TAG,subscriber,",".join(types),",".join(servers))
230
231 def _parse_string(string, type_root):
232     assert string.startswith(TAG), string
233     string = string[len(TAG):]
234     subscriber,types,servers = string.split("\t")
235     types = [libbe.diff.type_from_name(name, type_root) for name in types.split(",")]
236     return (subscriber,types,servers.split(","))
237
238 def _get_subscriber(extra_strings, subscriber, type_root):
239     for i,string in enumerate(extra_strings):
240         if string.startswith(TAG):
241             s,ts,srvs = _parse_string(string, type_root)
242             if s == subscriber:
243                 return i,s,ts,srvs # match!
244     return None # no match
245
246 # functions exposed to other modules
247
248 def subscribe(extra_strings, subscriber, types, servers, type_root):
249     args = _get_subscriber(extra_strings, subscriber, type_root)
250     if args == None: # no match
251         extra_strings.append(_generate_string(subscriber, types, servers))
252         return extra_strings
253     # Alter matched string
254     i,s,ts,srvs = args
255     for t in types:
256         if t not in ts:
257             ts.append(t)
258     # remove descendant types
259     all_ts = copy.copy(ts)
260     for t in all_ts:
261         for tt in all_ts:
262             if tt in ts and t.has_descendant(tt):
263                 ts.remove(tt)
264     if "*" in servers+srvs:
265         srvs = ["*"]
266     else:
267         srvs = list(set(servers+srvs))
268     extra_strings[i] = _generate_string(subscriber, ts, srvs)
269     return extra_strings
270
271 def unsubscribe(extra_strings, subscriber, types, servers, type_root):
272     args = _get_subscriber(extra_strings, subscriber, type_root)
273     if args == None: # no match
274         return extra_strings # pass
275     # Remove matched string
276     i,s,ts,srvs = args
277     all_ts = copy.copy(ts)
278     for t in types:
279         for tt in all_ts:
280             if tt in ts and t.has_descendant(tt):
281                 ts.remove(tt)
282     if "*" in servers+srvs:
283         srvs = []
284     else:
285         for srv in servers:
286             if srv in srvs:
287                 srvs.remove(srv)
288     if len(ts) == 0 or len(srvs) == 0:
289         extra_strings.pop(i)
290     else:
291         extra_strings[i] = _generate_string(subscriber, ts, srvs)
292     return extra_strings
293
294 def get_subscribers(extra_strings, type, server, type_root,
295                     match_ancestor_types=False,
296                     match_descendant_types=False):
297     """
298     Set match_ancestor_types=True if you want to find eveyone who
299     cares about your particular type.
300
301     Set match_descendant_types=True if you want to find subscribers
302     who may only care about some subset of your type.  This is useful
303     for generating lists of all the subscribers in a given set of
304     extra_strings.
305
306     >>> def sgs(*args, **kwargs):
307     ...     return sorted(get_subscribers(*args, **kwargs))
308     >>> es = []
309     >>> es = subscribe(es, "John Doe <j@doe.com>", [libbe.diff.BUGDIR_TYPE_ALL],
310     ...                ["a.com"], libbe.diff.BUGDIR_TYPE_ALL)
311     >>> es = subscribe(es, "Jane Doe <J@doe.com>", [libbe.diff.BUGDIR_TYPE_NEW],
312     ...                ["*"], libbe.diff.BUGDIR_TYPE_ALL)
313     >>> sgs(es, libbe.diff.BUGDIR_TYPE_ALL, "a.com", libbe.diff.BUGDIR_TYPE_ALL)
314     ['John Doe <j@doe.com>']
315     >>> sgs(es, libbe.diff.BUGDIR_TYPE_ALL, "a.com", libbe.diff.BUGDIR_TYPE_ALL,
316     ...     match_descendant_types=True)
317     ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>']
318     >>> sgs(es, libbe.diff.BUGDIR_TYPE_ALL, "b.net", libbe.diff.BUGDIR_TYPE_ALL,
319     ...     match_descendant_types=True)
320     ['Jane Doe <J@doe.com>']
321     >>> sgs(es, libbe.diff.BUGDIR_TYPE_NEW, "a.com", libbe.diff.BUGDIR_TYPE_ALL)
322     ['Jane Doe <J@doe.com>']
323     >>> sgs(es, libbe.diff.BUGDIR_TYPE_NEW, "a.com", libbe.diff.BUGDIR_TYPE_ALL,
324     ... match_ancestor_types=True)
325     ['Jane Doe <J@doe.com>', 'John Doe <j@doe.com>']
326     """
327     for string in extra_strings:
328         if not string.startswith(TAG):
329             continue
330         subscriber,types,servers = _parse_string(string, type_root)
331         type_match = False
332         if type in types:
333             type_match = True
334         if type_match == False and match_ancestor_types == True:
335             for t in types:
336                 if t.has_descendant(type):
337                     type_match = True
338                     break
339         if type_match == False and match_descendant_types == True:
340             for t in types:
341                 if type.has_descendant(t):
342                     type_match = True
343                     break
344         server_match = False
345         if server in servers or servers == ["*"] or server == "*":
346             server_match = True
347         if type_match == True and server_match == True:
348             yield subscriber
349
350 def get_bugdir_subscribers(bugdir, server):
351     """
352     I have a bugdir.  Who cares about it, and what do they care about?
353     Returns a dict of dicts:
354       subscribers[user][id] = types
355     where id is either a bug.uuid (in the case of a bug subscription)
356     or "%(bugdir_id)s" (in the case of a bugdir subscription).
357
358     Only checks bugs that are currently in memory, so you might want
359     to call bugdir.load_all_bugs() first.
360
361     >>> bd = bugdir.SimpleBugDir(sync_with_disk=False)
362     >>> a = bd.bug_from_shortname("a")
363     >>> bd.extra_strings = subscribe(bd.extra_strings, "John Doe <j@doe.com>",
364     ...                [libbe.diff.BUGDIR_TYPE_ALL], ["a.com"], libbe.diff.BUGDIR_TYPE_ALL)
365     >>> bd.extra_strings = subscribe(bd.extra_strings, "Jane Doe <J@doe.com>",
366     ...                [libbe.diff.BUGDIR_TYPE_NEW], ["*"], libbe.diff.BUGDIR_TYPE_ALL)
367     >>> a.extra_strings = subscribe(a.extra_strings, "John Doe <j@doe.com>",
368     ...                [libbe.diff.BUG_TYPE_ALL], ["a.com"], libbe.diff.BUG_TYPE_ALL)
369     >>> subscribers = get_bugdir_subscribers(bd, "a.com")
370     >>> subscribers["Jane Doe <J@doe.com>"]["%(bugdir_id)s"]
371     [<SubscriptionType: new>]
372     >>> subscribers["John Doe <j@doe.com>"]["%(bugdir_id)s"]
373     [<SubscriptionType: all>]
374     >>> subscribers["John Doe <j@doe.com>"]["a"]
375     [<SubscriptionType: all>]
376     >>> get_bugdir_subscribers(bd, "b.net")
377     {'Jane Doe <J@doe.com>': {'%(bugdir_id)s': [<SubscriptionType: new>]}}
378     >>> bd.cleanup()
379     """ % {'bugdir_id':libbe.diff.BUGDIR_ID}
380     subscribers = {}
381     for sub in get_subscribers(bugdir.extra_strings, libbe.diff.BUGDIR_TYPE_ALL,
382                                server, libbe.diff.BUGDIR_TYPE_ALL,
383                                match_descendant_types=True):
384         i,s,ts,srvs = _get_subscriber(bugdir.extra_strings, sub,
385                                       libbe.diff.BUGDIR_TYPE_ALL)
386         subscribers[sub] = {"DIR":ts}
387     for bug in bugdir:
388         for sub in get_subscribers(bug.extra_strings, libbe.diff.BUG_TYPE_ALL,
389                                    server, libbe.diff.BUG_TYPE_ALL,
390                                    match_descendant_types=True):
391             i,s,ts,srvs = _get_subscriber(bug.extra_strings, sub,
392                                           libbe.diff.BUG_TYPE_ALL)
393             if sub in subscribers:
394                 subscribers[sub][bug.uuid] = ts
395             else:
396                 subscribers[sub] = {bug.uuid:ts}
397     return subscribers