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