]> git.ozlabs.org Git - patchwork/blob - apps/patchwork/bin/pwclient
4714ffc1a1953771174d7d930c371e680a040323
[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         hash_n_id_actions = set(['hash', 'id', 'help'])
321         for subparsers_action in subparsers_actions:
322             for choice, subparser in subparsers_action.choices.items():
323                 # gross but the whole thing is..
324                 if (len(subparser._actions) == 3 \
325                     and set([a.dest for a in subparser._actions]) \
326                         == hash_n_id_actions) \
327                    or len(subparser._actions) == 0:
328                     continue
329                 print("command '{}'".format(choice))
330                 print(subparser.format_help())
331
332         parser.exit()
333
334 def main():
335     hash_parser = argparse.ArgumentParser(add_help=False, version=False)
336     hash_parser.add_argument(
337         '-h', metavar='HASH', dest='hash', action='store',
338         help='''Lookup by patch hash'''
339     )
340     hash_parser.add_argument(
341         'id', metavar='ID', nargs='*', action='store', type=int,
342         help='Patch ID',
343     )
344
345     filter_parser = argparse.ArgumentParser(add_help=False, version=False)
346     filter_parser.add_argument(
347         '-s', metavar='STATE',
348         help='''Filter by patch state (e.g., 'New', 'Accepted', etc.)'''
349     )
350     filter_parser.add_argument(
351         '-p', metavar='PROJECT',
352         help='''Filter by project name (see 'projects' for list)'''
353     )
354     filter_parser.add_argument(
355         '-w', metavar='WHO',
356         help='''Filter by submitter (name, e-mail substring search)'''
357     )
358     filter_parser.add_argument(
359         '-d', metavar='WHO',
360         help='''Filter by delegate (name, e-mail substring search)'''
361     )
362     filter_parser.add_argument(
363         '-n', metavar='MAX#',
364         type=int,
365         help='''Restrict number of results'''
366     )
367     filter_parser.add_argument(
368         '-m', metavar='MESSAGEID',
369         help='''Filter by Message-Id'''
370     )
371     filter_parser.add_argument(
372         'patch_name', metavar='STR', nargs='?',
373         help='substring to search for patches by name',
374     )
375     help_parser = argparse.ArgumentParser(add_help=False, version=False)
376     help_parser.add_argument(
377         '--help', action='help', help=argparse.SUPPRESS,
378         #help='''show this help message and exit'''
379     )
380
381     action_parser = argparse.ArgumentParser(
382         prog='pwclient',
383         add_help=False,
384         version=False,
385         formatter_class=argparse.RawDescriptionHelpFormatter,
386         epilog='''(apply | get | info | view | update) (-h HASH | ID [ID ...])''',
387     )
388     action_parser.add_argument(
389         '--help',
390         #action='help',
391         action=_RecursiveHelpAction,
392         help='''Print this help text'''
393     )
394
395     subparsers = action_parser.add_subparsers(
396         title='Commands',
397         metavar=''
398     )
399     apply_parser = subparsers.add_parser(
400         'apply', parents=[hash_parser, help_parser],
401         add_help=False,
402         help='''Apply a patch (in the current dir, using -p1)'''
403     )
404     apply_parser.set_defaults(subcmd='apply')
405     git_am_parser = subparsers.add_parser(
406         'git-am', parents=[hash_parser, help_parser],
407         add_help=False,
408         help='''Apply a patch to current git branch using "git am".'''
409     )
410     git_am_parser.set_defaults(subcmd='git_am')
411     git_am_parser.add_argument(
412         '-s', '--signoff',
413         action='store_true',
414         help='''pass --signoff to git-am'''
415     )
416     get_parser = subparsers.add_parser(
417         'get', parents=[hash_parser, help_parser],
418         add_help=False,
419         help='''Download a patch and save it locally'''
420     )
421     get_parser.set_defaults(subcmd='get')
422     info_parser = subparsers.add_parser(
423         'info', parents=[hash_parser, help_parser],
424         add_help=False,
425         help='''Display patchwork info about a given patch ID'''
426     )
427     info_parser.set_defaults(subcmd='info')
428     projects_parser = subparsers.add_parser(
429         'projects',
430         add_help=False,
431         help='''List all projects'''
432     )
433     projects_parser.set_defaults(subcmd='projects')
434     states_parser = subparsers.add_parser(
435         'states',
436         add_help=False,
437         help='''Show list of potential patch states'''
438     )
439     states_parser.set_defaults(subcmd='states')
440     view_parser = subparsers.add_parser(
441         'view', parents=[hash_parser, help_parser],
442         add_help=False,
443         help='''View a patch'''
444     )
445     view_parser.set_defaults(subcmd='view')
446     update_parser = subparsers.add_parser(
447         'update', parents=[hash_parser, help_parser],
448         add_help=False,
449         help='''Update patch''',
450         epilog='''Using a COMMIT-REF allows for only one ID to be specified''',
451     )
452     update_parser.add_argument(
453         '-c', metavar='COMMIT-REF',
454         help='''commit reference hash'''
455     )
456     update_parser.add_argument(
457         '-s', metavar='STATE',
458         required=True,
459         help='''Set patch state (e.g., 'Accepted', 'Superseded' etc.)'''
460     )
461     update_parser.set_defaults(subcmd='update')
462     list_parser = subparsers.add_parser("list",
463         add_help=False,
464         #aliases=['search'],
465         parents=[filter_parser, help_parser],
466         help='''List patches, using the optional filters specified
467         below and an optional substring to search for patches
468         by name'''
469     )
470     list_parser.set_defaults(subcmd='list')
471     search_parser = subparsers.add_parser("search",
472         add_help=False,
473         parents=[filter_parser, help_parser],
474         help='''Alias for "list"'''
475     )
476     # Poor man's argparse aliases:
477     # We register the "search" parser but effectively use "list" for the
478     # help-text.
479     search_parser.set_defaults(subcmd='list')
480     if len(sys.argv) < 2:
481         action_parser.print_help()
482         sys.exit(0)
483
484     args = action_parser.parse_args()
485     args=dict(vars(args))
486     action = args.get('subcmd')
487
488     if args.get('hash') and len(args.get('id')):
489         # mimic mutual exclusive group
490         sys.stderr.write("Error: [-h HASH] and [ID [ID ...]] " +
491           "are mutually exlusive\n")
492         locals()[action + '_parser'].print_help()
493         sys.exit(1)
494
495     # set defaults
496     filt = Filter()
497     submitter_str = ""
498     delegate_str = ""
499     project_str = ""
500     commit_str = ""
501     state_str = ""
502     hash_str = None
503     msgid_str = ""
504     patch_ids = None
505     url = DEFAULT_URL
506
507     if args.get('s'):
508         state_str = args.get('s')
509     if args.get('p'):
510         project_str = args.get('p')
511     if args.get('w'):
512         submitter_str = args.get('w')
513     if args.get('d'):
514         delegate_str = args.get('d')
515     if args.get('hash'):
516         hash_str = args.get('hash')
517     if args.get('id'):
518         patch_ids = args.get('id')
519     if args.get('c'):
520         # update multiple IDs with a single commit-hash does not make sense
521         if action == 'update' and patch_ids and len(patch_ids) > 1:
522             sys.stderr.write(
523               "Declining update with COMMIT-REF on multiple IDs\n"
524             )
525             update_parser.print_help()
526             sys.exit(1)
527         commit_str = args.get('c')
528     if args.get('m'):
529         msgid_str = args.get('m')
530     if args.get('n') != None:
531         try:
532             filt.add("max_count", args.get('n'))
533         except:
534             sys.stderr.write("Invalid maximum count '%s'\n" % args.get('n'))
535             action_parser.print_help()
536             sys.exit(1)
537
538     do_signoff = args.get('signoff')
539
540     # grab settings from config files
541     config = ConfigParser.ConfigParser()
542     config.read([CONFIG_FILE])
543
544     if not config.has_section('options'):
545         sys.stderr.write('~/.pwclientrc is in the old format. Migrating it...')
546
547         old_project = config.get('base','project')
548
549         new_config = ConfigParser.ConfigParser()
550         new_config.add_section('options')
551
552         new_config.set('options','default',old_project)
553         new_config.add_section(old_project)
554
555         new_config.set(old_project,'url',config.get('base','url'))
556         if config.has_option('auth', 'username'):
557             new_config.set(old_project,'username',config.get('auth','username'))
558         if config.has_option('auth', 'password'):
559             new_config.set(old_project,'password',config.get('auth','password'))
560
561         old_config_file = CONFIG_FILE + '.orig'
562         shutil.copy2(CONFIG_FILE,old_config_file)
563
564         with open(CONFIG_FILE, 'wb') as fd:
565             new_config.write(fd)
566
567         sys.stderr.write(' Done.\n')
568         sys.stderr.write('Your old ~/.pwclientrc was saved to %s\n' % old_config_file)
569         sys.stderr.write('and was converted to the new format. You may want to\n')
570         sys.stderr.write('inspect it before continuing.\n')
571         sys.exit(1)
572
573     if not project_str:
574         try:
575             project_str = config.get('options', 'default')
576         except:
577             sys.stderr.write("No default project configured in ~/.pwclientrc\n")
578             action_parser.print_help()
579             sys.exit(1)
580
581     if not config.has_section(project_str):
582         sys.stderr.write("No section for project %s\n" % project_str)
583         sys.exit(1)
584
585     if not config.has_option(project_str, 'url'):
586         sys.stderr.write("No URL for project %s\n" % project_str)
587         sys.exit(1)
588     if not do_signoff and config.has_option('options', 'signoff'):
589         do_signoff = config.getboolean('options', 'signoff')
590     if not do_signoff and config.has_option(project_str, 'signoff'):
591         do_signoff = config.getboolean(project_str, 'signoff')
592
593     url = config.get(project_str, 'url')
594
595     (username, password) = (None, None)
596     transport = None
597     if action in auth_actions:
598         if config.has_option(project_str, 'username') and \
599                 config.has_option(project_str, 'password'):
600
601             use_https = url.startswith('https')
602
603             transport = BasicHTTPAuthTransport( \
604                     config.get(project_str, 'username'),
605                     config.get(project_str, 'password'),
606                     use_https)
607
608         else:
609             sys.stderr.write(("The %s action requires authentication, "
610                     "but no username or password\nis configured\n") % action)
611             sys.exit(1)
612
613     if project_str:
614         filt.add("project", project_str)
615
616     if state_str:
617         filt.add("state", state_str)
618
619     if msgid_str:
620         filt.add("msgid", msgid_str)
621
622     try:
623         rpc = xmlrpclib.Server(url, transport = transport)
624     except:
625         sys.stderr.write("Unable to connect to %s\n" % url)
626         sys.exit(1)
627
628     # It should be safe to assume hash_str is not zero, but who knows..
629     if hash_str != None:
630         patch_ids = [patch_id_from_hash(rpc, project_str, hash_str)]
631
632     # helper for non_empty() to print correct helptext
633     h = locals()[action + '_parser']
634
635     # Require either hash_str or IDs for
636     def non_empty(h, patch_ids):
637         """Error out if no patch IDs were specified"""
638         if patch_ids == None or len(patch_ids) < 1:
639             sys.stderr.write("Error: Missing Argument! " +
640               "Either [-h HASH] or [ID [ID ...]] are required\n")
641             if h:
642                 h.print_help()
643             sys.exit(1)
644         return patch_ids
645
646     if action == 'list' or action == 'search':
647         if args.get('patch_name') != None:
648             filt.add("name__icontains", args.get('patch_name'))
649         action_list(rpc, filt, submitter_str, delegate_str)
650
651     elif action.startswith('project'):
652         action_projects(rpc)
653
654     elif action.startswith('state'):
655         action_states(rpc)
656
657     elif action == 'view':
658         for patch_id in non_empty(h, patch_ids):
659             s = rpc.patch_get_mbox(patch_id)
660             if len(s) > 0:
661                 print unicode(s).encode("utf-8")
662
663     elif action in ('get', 'save', 'info'):
664         if action == 'info':
665             [action_info(rpc, patch_id) for patch_id in non_empty(h, patch_ids)]
666         else:
667             [action_get(rpc, patch_id) for patch_id in non_empty(h, patch_ids)]
668
669     elif action == 'apply':
670         [action_apply(rpc, patch_id) for patch_id in non_empty(h, patch_ids)]
671
672     elif action == 'git_am':
673         cmd = ['git', 'am']
674         if do_signoff:
675             cmd.append('-s')
676         [action_apply(rpc, patch_id, cmd) for patch_id in
677          non_empty(h, patch_ids)]
678
679     elif action == 'update':
680         [action_update_patch(rpc, patch_id, state = state_str,
681                 commit = commit_str
682          ) for patch_id in non_empty(h, patch_ids)]
683
684     else:
685         sys.stderr.write("Unknown action '%s'\n" % action)
686         action_parser.print_help()
687         sys.exit(1)
688
689 if __name__ == "__main__":
690     main()