]> git.ozlabs.org Git - patchwork/blob - apps/patchwork/bin/pwclient
pwclient: add output format option to list/search command
[patchwork] / apps / patchwork / bin / pwclient
1 #!/usr/bin/env python
2 #
3 # Patchwork command line client
4 # Copyright (C) 2008 Nate Case <ncase@xes-inc.com>
5 #
6 # This file is part of the Patchwork package.
7 #
8 # Patchwork is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 2 of the License, or
11 # (at your option) any later version.
12 #
13 # Patchwork is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with Patchwork; if not, write to the Free Software
20 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
21
22 import os
23 import sys
24 import xmlrpclib
25 import argparse
26 import string
27 import tempfile
28 import subprocess
29 import base64
30 import ConfigParser
31 import shutil
32 import re
33
34 # Default Patchwork remote XML-RPC server URL
35 # This script will check the PW_XMLRPC_URL environment variable
36 # for the URL to access.  If that is unspecified, it will fallback to
37 # the hardcoded default value specified here.
38 DEFAULT_URL = "http://patchwork/xmlrpc/"
39 CONFIG_FILE = os.path.expanduser('~/.pwclientrc')
40
41 class Filter:
42     """Filter for selecting patches."""
43     def __init__(self):
44         # These fields refer to specific objects, so they are special
45         # because we have to resolve them to IDs before passing the
46         # filter to the server
47         self.state = ""
48         self.project = ""
49
50         # The dictionary that gets passed to via XML-RPC
51         self.d = {}
52
53     def add(self, field, value):
54         if field == 'state':
55             self.state = value
56         elif field == 'project':
57             self.project = value
58         else:
59             # OK to add directly
60             self.d[field] = value
61
62     def resolve_ids(self, rpc):
63         """Resolve State, Project, and Person IDs based on filter strings."""
64         if self.state != "":
65             id = state_id_by_name(rpc, self.state)
66             if id == 0:
67                 sys.stderr.write("Note: No State found matching %s*, " \
68                                  "ignoring filter\n" % self.state)
69             else:
70                 self.d['state_id'] = id
71
72         if self.project != None:
73             id = project_id_by_name(rpc, self.project)
74             if id == 0:
75                 sys.stderr.write("Note: No Project found matching %s, " \
76                                  "ignoring filter\n" % self.project)
77             else:
78                 self.d['project_id'] = id
79
80     def __str__(self):
81         """Return human-readable description of the filter."""
82         return str(self.d)
83
84 class BasicHTTPAuthTransport(xmlrpclib.SafeTransport):
85
86     def __init__(self, username = None, password = None, use_https = False):
87         self.username = username
88         self.password = password
89         self.use_https = use_https
90         xmlrpclib.SafeTransport.__init__(self)
91
92     def authenticated(self):
93         return self.username != None and self.password != None
94
95     def send_host(self, connection, host):
96         xmlrpclib.Transport.send_host(self, connection, host)
97         if not self.authenticated():
98             return
99         credentials = '%s:%s' % (self.username, self.password)
100         auth = 'Basic ' + base64.encodestring(credentials).strip()
101         connection.putheader('Authorization', auth)
102
103     def make_connection(self, host):
104         if self.use_https:
105             fn = xmlrpclib.SafeTransport.make_connection
106         else:
107             fn = xmlrpclib.Transport.make_connection
108         return fn(self, host)
109
110 def project_id_by_name(rpc, linkname):
111     """Given a project short name, look up the Project ID."""
112     if len(linkname) == 0:
113         return 0
114     projects = rpc.project_list(linkname, 0)
115     for project in projects:
116         if project['linkname'] == linkname:
117             return project['id']
118     return 0
119
120 def state_id_by_name(rpc, name):
121     """Given a partial state name, look up the state ID."""
122     if len(name) == 0:
123         return 0
124     states = rpc.state_list(name, 0)
125     for state in states:
126         if state['name'].lower().startswith(name.lower()):
127             return state['id']
128     return 0
129
130 def person_ids_by_name(rpc, name):
131     """Given a partial name or email address, return a list of the
132     person IDs that match."""
133     if len(name) == 0:
134         return []
135     people = rpc.person_list(name, 0)
136     return map(lambda x: x['id'], people)
137
138 def list_patches(patches, format_str=None):
139     """Dump a list of patches to stdout."""
140     if format_str:
141         format_field_re = re.compile("%{([a-z0-9_]+)}")
142
143         def patch_field(matchobj):
144             fieldname = matchobj.group(1)
145             return str(patch[fieldname])
146
147         for patch in patches:
148             print(format_field_re.sub(patch_field, format_str))
149     else:
150         print("%-7s %-12s %s" % ("ID", "State", "Name"))
151         print("%-7s %-12s %s" % ("--", "-----", "----"))
152         for patch in patches:
153             print("%-7d %-12s %s" % (patch['id'], patch['state'], patch['name']))
154
155 def action_list(rpc, filter, submitter_str, delegate_str, format_str=None):
156     filter.resolve_ids(rpc)
157
158     if submitter_str != None:
159         ids = person_ids_by_name(rpc, submitter_str)
160         if len(ids) == 0:
161             sys.stderr.write("Note: Nobody found matching *%s*\n" % \
162                              submitter_str)
163         else:
164             for id in ids:
165                 person = rpc.person_get(id)
166                 print "Patches submitted by %s <%s>:" % \
167                         (unicode(person['name']).encode("utf-8"), \
168                          unicode(person['email']).encode("utf-8"))
169                 f = filter
170                 f.add("submitter_id", id)
171                 patches = rpc.patch_list(f.d)
172                 list_patches(patches, format_str)
173         return
174
175     if delegate_str != None:
176         ids = person_ids_by_name(rpc, delegate_str)
177         if len(ids) == 0:
178             sys.stderr.write("Note: Nobody found matching *%s*\n" % \
179                              delegate_str)
180         else:
181             for id in ids:
182                 person = rpc.person_get(id)
183                 print "Patches delegated to %s <%s>:" % \
184                         (person['name'], person['email'])
185                 f = filter
186                 f.add("delegate_id", id)
187                 patches = rpc.patch_list(f.d)
188                 list_patches(patches, format_str)
189         return
190
191     patches = rpc.patch_list(filter.d)
192     list_patches(patches, format_str)
193
194 def action_projects(rpc):
195     projects = rpc.project_list("", 0)
196     print("%-5s %-24s %s" % ("ID", "Name", "Description"))
197     print("%-5s %-24s %s" % ("--", "----", "-----------"))
198     for project in projects:
199         print("%-5d %-24s %s" % (project['id'], \
200                 project['linkname'], \
201                 project['name']))
202
203 def action_states(rpc):
204     states = rpc.state_list("", 0)
205     print("%-5s %s" % ("ID", "Name"))
206     print("%-5s %s" % ("--", "----"))
207     for state in states:
208         print("%-5d %s" % (state['id'], state['name']))
209
210 def action_info(rpc, patch_id):
211     patch = rpc.patch_get(patch_id)
212     s = "Information for patch id %d" % (patch_id)
213     print(s)
214     print('-' * len(s))
215     for key, value in sorted(patch.iteritems()):
216         print("- %- 14s: %s" % (key, unicode(value).encode("utf-8")))
217
218 def action_get(rpc, patch_id):
219     patch = rpc.patch_get(patch_id)
220     s = rpc.patch_get_mbox(patch_id)
221
222     if patch == {} or len(s) == 0:
223         sys.stderr.write("Unable to get patch %d\n" % patch_id)
224         sys.exit(1)
225
226     base_fname = fname = os.path.basename(patch['filename'])
227     i = 0
228     while os.path.exists(fname):
229         fname = "%s.%d" % (base_fname, i)
230         i += 1
231
232     try:
233         f = open(fname, "w")
234     except:
235         sys.stderr.write("Unable to open %s for writing\n" % fname)
236         sys.exit(1)
237
238     try:
239         f.write(unicode(s).encode("utf-8"))
240         f.close()
241         print "Saved patch to %s" % fname
242     except:
243         sys.stderr.write("Failed to write to %s\n" % fname)
244         sys.exit(1)
245
246 def action_apply(rpc, patch_id, apply_cmd=None):
247     patch = rpc.patch_get(patch_id)
248     if patch == {}:
249         sys.stderr.write("Error getting information on patch ID %d\n" % \
250                          patch_id)
251         sys.exit(1)
252
253     if apply_cmd is None:
254       print "Applying patch #%d to current directory" % patch_id
255       apply_cmd = ['patch', '-p1']
256     else:
257       print "Applying patch #%d using %s" % (
258           patch_id, repr(' '.join(apply_cmd)))
259
260     print "Description: %s" % patch['name']
261     s = rpc.patch_get_mbox(patch_id)
262     if len(s) > 0:
263         proc = subprocess.Popen(apply_cmd, stdin = subprocess.PIPE)
264         proc.communicate(unicode(s).encode('utf-8'))
265     else:
266         sys.stderr.write("Error: No patch content found\n")
267         sys.exit(1)
268
269 def action_update_patch(rpc, patch_id, state = None, commit = None):
270     patch = rpc.patch_get(patch_id)
271     if patch == {}:
272         sys.stderr.write("Error getting information on patch ID %d\n" % \
273                          patch_id)
274         sys.exit(1)
275
276     params = {}
277
278     if state:
279         state_id = state_id_by_name(rpc, state)
280         if state_id == 0:
281             sys.stderr.write("Error: No State found matching %s*\n" % state)
282             sys.exit(1)
283         params['state'] = state_id
284
285     if commit:
286         params['commit_ref'] = commit
287
288     success = False
289     try:
290         success = rpc.patch_set(patch_id, params)
291     except xmlrpclib.Fault, f:
292         sys.stderr.write("Error updating patch: %s\n" % f.faultString)
293
294     if not success:
295         sys.stderr.write("Patch not updated\n")
296
297 def patch_id_from_hash(rpc, project, hash):
298     try:
299         patch = rpc.patch_get_by_project_hash(project, hash)
300     except xmlrpclib.Fault:
301         # the server may not have the newer patch_get_by_project_hash function,
302         # so fall back to hash-only.
303         patch = rpc.patch_get_by_hash(hash)
304
305     if patch == {}:
306         sys.stderr.write("No patch has the hash provided\n")
307         sys.exit(1)
308
309     patch_id = patch['id']
310     # be super paranoid
311     try:
312         patch_id = int(patch_id)
313     except:
314         sys.stderr.write("Invalid patch ID obtained from server\n")
315         sys.exit(1)
316     return patch_id
317
318 auth_actions = ['update']
319
320 # unfortunately we currently have to revert to this ugly hack..
321 class _RecursiveHelpAction(argparse._HelpAction):
322
323     def __call__(self, parser, namespace, values, option_string=None):
324         parser.print_help()
325         print
326
327         subparsers_actions = [
328             action for action in parser._actions
329             if isinstance(action, argparse._SubParsersAction)
330         ]
331         hash_n_id_actions = set(['hash', 'id', 'help'])
332         for subparsers_action in subparsers_actions:
333             for choice, subparser in subparsers_action.choices.items():
334                 # gross but the whole thing is..
335                 if (len(subparser._actions) == 3 \
336                     and set([a.dest for a in subparser._actions]) \
337                         == hash_n_id_actions) \
338                    or len(subparser._actions) == 0:
339                     continue
340                 print("command '{}'".format(choice))
341                 print(subparser.format_help())
342
343         parser.exit()
344
345 def main():
346     hash_parser = argparse.ArgumentParser(add_help=False, version=False)
347     hash_parser.add_argument(
348         '-h', metavar='HASH', dest='hash', action='store',
349         help='''Lookup by patch hash'''
350     )
351     hash_parser.add_argument(
352         'id', metavar='ID', nargs='*', action='store', type=int,
353         help='Patch ID',
354     )
355
356     filter_parser = argparse.ArgumentParser(add_help=False, version=False)
357     filter_parser.add_argument(
358         '-s', metavar='STATE',
359         help='''Filter by patch state (e.g., 'New', 'Accepted', etc.)'''
360     )
361     filter_parser.add_argument(
362         '-p', metavar='PROJECT',
363         help='''Filter by project name (see 'projects' for list)'''
364     )
365     filter_parser.add_argument(
366         '-w', metavar='WHO',
367         help='''Filter by submitter (name, e-mail substring search)'''
368     )
369     filter_parser.add_argument(
370         '-d', metavar='WHO',
371         help='''Filter by delegate (name, e-mail substring search)'''
372     )
373     filter_parser.add_argument(
374         '-n', metavar='MAX#',
375         type=int,
376         help='''Restrict number of results'''
377     )
378     filter_parser.add_argument(
379         '-m', metavar='MESSAGEID',
380         help='''Filter by Message-Id'''
381     )
382     filter_parser.add_argument(
383         '-f', metavar='FORMAT',
384         help='''Print output in the given format. You can use tags matching '''
385             '''fields, e.g. %%{id}, %%{state}, or %%{msgid}.'''
386     )
387     filter_parser.add_argument(
388         'patch_name', metavar='STR', nargs='?',
389         help='substring to search for patches by name',
390     )
391     help_parser = argparse.ArgumentParser(add_help=False, version=False)
392     help_parser.add_argument(
393         '--help', action='help', help=argparse.SUPPRESS,
394         #help='''show this help message and exit'''
395     )
396
397     action_parser = argparse.ArgumentParser(
398         prog='pwclient',
399         add_help=False,
400         version=False,
401         formatter_class=argparse.RawDescriptionHelpFormatter,
402         epilog='''(apply | get | info | view | update) (-h HASH | ID [ID ...])''',
403     )
404     action_parser.add_argument(
405         '--help',
406         #action='help',
407         action=_RecursiveHelpAction,
408         help='''Print this help text'''
409     )
410
411     subparsers = action_parser.add_subparsers(
412         title='Commands',
413         metavar=''
414     )
415     apply_parser = subparsers.add_parser(
416         'apply', parents=[hash_parser, help_parser],
417         add_help=False,
418         help='''Apply a patch (in the current dir, using -p1)'''
419     )
420     apply_parser.set_defaults(subcmd='apply')
421     git_am_parser = subparsers.add_parser(
422         'git-am', parents=[hash_parser, help_parser],
423         add_help=False,
424         help='''Apply a patch to current git branch using "git am".'''
425     )
426     git_am_parser.set_defaults(subcmd='git_am')
427     git_am_parser.add_argument(
428         '-s', '--signoff',
429         action='store_true',
430         help='''pass --signoff to git-am'''
431     )
432     get_parser = subparsers.add_parser(
433         'get', parents=[hash_parser, help_parser],
434         add_help=False,
435         help='''Download a patch and save it locally'''
436     )
437     get_parser.set_defaults(subcmd='get')
438     info_parser = subparsers.add_parser(
439         'info', parents=[hash_parser, help_parser],
440         add_help=False,
441         help='''Display patchwork info about a given patch ID'''
442     )
443     info_parser.set_defaults(subcmd='info')
444     projects_parser = subparsers.add_parser(
445         'projects',
446         add_help=False,
447         help='''List all projects'''
448     )
449     projects_parser.set_defaults(subcmd='projects')
450     states_parser = subparsers.add_parser(
451         'states',
452         add_help=False,
453         help='''Show list of potential patch states'''
454     )
455     states_parser.set_defaults(subcmd='states')
456     view_parser = subparsers.add_parser(
457         'view', parents=[hash_parser, help_parser],
458         add_help=False,
459         help='''View a patch'''
460     )
461     view_parser.set_defaults(subcmd='view')
462     update_parser = subparsers.add_parser(
463         'update', parents=[hash_parser, help_parser],
464         add_help=False,
465         help='''Update patch''',
466         epilog='''Using a COMMIT-REF allows for only one ID to be specified''',
467     )
468     update_parser.add_argument(
469         '-c', metavar='COMMIT-REF',
470         help='''commit reference hash'''
471     )
472     update_parser.add_argument(
473         '-s', metavar='STATE',
474         required=True,
475         help='''Set patch state (e.g., 'Accepted', 'Superseded' etc.)'''
476     )
477     update_parser.set_defaults(subcmd='update')
478     list_parser = subparsers.add_parser("list",
479         add_help=False,
480         #aliases=['search'],
481         parents=[filter_parser, help_parser],
482         help='''List patches, using the optional filters specified
483         below and an optional substring to search for patches
484         by name'''
485     )
486     list_parser.set_defaults(subcmd='list')
487     search_parser = subparsers.add_parser("search",
488         add_help=False,
489         parents=[filter_parser, help_parser],
490         help='''Alias for "list"'''
491     )
492     # Poor man's argparse aliases:
493     # We register the "search" parser but effectively use "list" for the
494     # help-text.
495     search_parser.set_defaults(subcmd='list')
496     if len(sys.argv) < 2:
497         action_parser.print_help()
498         sys.exit(0)
499
500     args = action_parser.parse_args()
501     args = dict(vars(args))
502     action = args.get('subcmd')
503
504     if args.get('hash') and len(args.get('id')):
505         # mimic mutual exclusive group
506         sys.stderr.write("Error: [-h HASH] and [ID [ID ...]] " +
507           "are mutually exlusive\n")
508         locals()[action + '_parser'].print_help()
509         sys.exit(1)
510
511     # set defaults
512     filt = Filter()
513     commit_str = None
514     url = DEFAULT_URL
515
516     state_str = args.get('s')
517     project_str = args.get('p')
518     submitter_str = args.get('w')
519     delegate_str = args.get('d')
520     format_str = args.get('f')
521     hash_str = args.get('hash')
522     patch_ids = args.get('id')
523     msgid_str = args.get('m')
524     if args.get('c'):
525         # update multiple IDs with a single commit-hash does not make sense
526         if action == 'update' and patch_ids and len(patch_ids) > 1:
527             sys.stderr.write(
528               "Declining update with COMMIT-REF on multiple IDs\n"
529             )
530             update_parser.print_help()
531             sys.exit(1)
532         commit_str = args.get('c')
533
534     if args.get('n') != None:
535         try:
536             filt.add("max_count", args.get('n'))
537         except:
538             sys.stderr.write("Invalid maximum count '%s'\n" % args.get('n'))
539             action_parser.print_help()
540             sys.exit(1)
541
542     do_signoff = args.get('signoff')
543
544     # grab settings from config files
545     config = ConfigParser.ConfigParser()
546     config.read([CONFIG_FILE])
547
548     if not config.has_section('options'):
549         sys.stderr.write('~/.pwclientrc is in the old format. Migrating it...')
550
551         old_project = config.get('base','project')
552
553         new_config = ConfigParser.ConfigParser()
554         new_config.add_section('options')
555
556         new_config.set('options','default',old_project)
557         new_config.add_section(old_project)
558
559         new_config.set(old_project,'url',config.get('base','url'))
560         if config.has_option('auth', 'username'):
561             new_config.set(old_project,'username',config.get('auth','username'))
562         if config.has_option('auth', 'password'):
563             new_config.set(old_project,'password',config.get('auth','password'))
564
565         old_config_file = CONFIG_FILE + '.orig'
566         shutil.copy2(CONFIG_FILE,old_config_file)
567
568         with open(CONFIG_FILE, 'wb') as fd:
569             new_config.write(fd)
570
571         sys.stderr.write(' Done.\n')
572         sys.stderr.write('Your old ~/.pwclientrc was saved to %s\n' % old_config_file)
573         sys.stderr.write('and was converted to the new format. You may want to\n')
574         sys.stderr.write('inspect it before continuing.\n')
575         sys.exit(1)
576
577     if not project_str:
578         try:
579             project_str = config.get('options', 'default')
580         except:
581             sys.stderr.write("No default project configured in ~/.pwclientrc\n")
582             action_parser.print_help()
583             sys.exit(1)
584
585     if not config.has_section(project_str):
586         sys.stderr.write("No section for project %s\n" % project_str)
587         sys.exit(1)
588     if not config.has_option(project_str, 'url'):
589         sys.stderr.write("No URL for project %s\n" % project_str)
590         sys.exit(1)
591     if not do_signoff and config.has_option('options', 'signoff'):
592         do_signoff = config.getboolean('options', 'signoff')
593     if not do_signoff and config.has_option(project_str, 'signoff'):
594         do_signoff = config.getboolean(project_str, 'signoff')
595
596     url = config.get(project_str, 'url')
597
598     (username, password) = (None, None)
599     transport = None
600     if action in auth_actions:
601         if config.has_option(project_str, 'username') and \
602                 config.has_option(project_str, 'password'):
603
604             use_https = url.startswith('https')
605
606             transport = BasicHTTPAuthTransport( \
607                     config.get(project_str, 'username'),
608                     config.get(project_str, 'password'),
609                     use_https)
610
611         else:
612             sys.stderr.write(("The %s action requires authentication, "
613                     "but no username or password\nis configured\n") % action)
614             sys.exit(1)
615
616     if project_str:
617         filt.add("project", project_str)
618
619     if state_str:
620         filt.add("state", state_str)
621
622     if msgid_str:
623         filt.add("msgid", msgid_str)
624
625     try:
626         rpc = xmlrpclib.Server(url, transport = transport)
627     except:
628         sys.stderr.write("Unable to connect to %s\n" % url)
629         sys.exit(1)
630
631     # It should be safe to assume hash_str is not zero, but who knows..
632     if hash_str != None:
633         patch_ids = [patch_id_from_hash(rpc, project_str, hash_str)]
634
635     # helper for non_empty() to print correct helptext
636     h = locals()[action + '_parser']
637
638     # Require either hash_str or IDs for
639     def non_empty(h, patch_ids):
640         """Error out if no patch IDs were specified"""
641         if patch_ids == None or len(patch_ids) < 1:
642             sys.stderr.write("Error: Missing Argument! " +
643               "Either [-h HASH] or [ID [ID ...]] are required\n")
644             if h:
645                 h.print_help()
646             sys.exit(1)
647         return patch_ids
648
649     if action == 'list' or action == 'search':
650         if args.get('patch_name') != None:
651             filt.add("name__icontains", args.get('patch_name'))
652         action_list(rpc, filt, submitter_str, delegate_str, format_str)
653
654     elif action.startswith('project'):
655         action_projects(rpc)
656
657     elif action.startswith('state'):
658         action_states(rpc)
659
660     elif action == 'view':
661         for patch_id in non_empty(h, patch_ids):
662             s = rpc.patch_get_mbox(patch_id)
663             if len(s) > 0:
664                 print unicode(s).encode("utf-8")
665
666     elif action == 'info':
667         for patch_id in non_empty(h, patch_ids):
668             action_info(rpc, patch_id)
669
670     elif action == 'get':
671         for patch_id in non_empty(h, patch_ids):
672             action_get(rpc, patch_id)
673
674     elif action == 'apply':
675         for patch_id in non_empty(h, patch_ids):
676             action_apply(rpc, patch_id)
677
678     elif action == 'git_am':
679         cmd = ['git', 'am']
680         if do_signoff:
681             cmd.append('-s')
682         for patch_id in non_empty(h, patch_ids):
683             action_apply(rpc, patch_id, cmd)
684
685     elif action == 'update':
686         for patch_id in non_empty(h, patch_ids):
687             action_update_patch(rpc, patch_id, state = state_str,
688                 commit = commit_str
689             )
690
691     else:
692         sys.stderr.write("Unknown action '%s'\n" % action)
693         action_parser.print_help()
694         sys.exit(1)
695
696 if __name__ == "__main__":
697     main()