]> git.ozlabs.org Git - patchwork/blob - apps/patchwork/bin/pwclient
b12e8161e84d89b1f386459d21084648d6403270
[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 != None:
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 != None:
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 != None:
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     commit_str = None
498     url = DEFAULT_URL
499
500     state_str = args.get('s')
501     project_str = args.get('p')
502     submitter_str = args.get('w')
503     delegate_str = args.get('d')
504     hash_str = args.get('hash')
505     patch_ids = args.get('id')
506     msgid_str = args.get('m')
507     if args.get('c'):
508         # update multiple IDs with a single commit-hash does not make sense
509         if action == 'update' and patch_ids and len(patch_ids) > 1:
510             sys.stderr.write(
511               "Declining update with COMMIT-REF on multiple IDs\n"
512             )
513             update_parser.print_help()
514             sys.exit(1)
515         commit_str = args.get('c')
516
517     if args.get('n') != None:
518         try:
519             filt.add("max_count", args.get('n'))
520         except:
521             sys.stderr.write("Invalid maximum count '%s'\n" % args.get('n'))
522             action_parser.print_help()
523             sys.exit(1)
524
525     do_signoff = args.get('signoff')
526
527     # grab settings from config files
528     config = ConfigParser.ConfigParser()
529     config.read([CONFIG_FILE])
530
531     if not config.has_section('options'):
532         sys.stderr.write('~/.pwclientrc is in the old format. Migrating it...')
533
534         old_project = config.get('base','project')
535
536         new_config = ConfigParser.ConfigParser()
537         new_config.add_section('options')
538
539         new_config.set('options','default',old_project)
540         new_config.add_section(old_project)
541
542         new_config.set(old_project,'url',config.get('base','url'))
543         if config.has_option('auth', 'username'):
544             new_config.set(old_project,'username',config.get('auth','username'))
545         if config.has_option('auth', 'password'):
546             new_config.set(old_project,'password',config.get('auth','password'))
547
548         old_config_file = CONFIG_FILE + '.orig'
549         shutil.copy2(CONFIG_FILE,old_config_file)
550
551         with open(CONFIG_FILE, 'wb') as fd:
552             new_config.write(fd)
553
554         sys.stderr.write(' Done.\n')
555         sys.stderr.write('Your old ~/.pwclientrc was saved to %s\n' % old_config_file)
556         sys.stderr.write('and was converted to the new format. You may want to\n')
557         sys.stderr.write('inspect it before continuing.\n')
558         sys.exit(1)
559
560     if not project_str:
561         try:
562             project_str = config.get('options', 'default')
563         except:
564             sys.stderr.write("No default project configured in ~/.pwclientrc\n")
565             action_parser.print_help()
566             sys.exit(1)
567
568     if not config.has_section(project_str):
569         sys.stderr.write("No section for project %s\n" % project_str)
570         sys.exit(1)
571     if not config.has_option(project_str, 'url'):
572         sys.stderr.write("No URL for project %s\n" % project_str)
573         sys.exit(1)
574     if not do_signoff and config.has_option('options', 'signoff'):
575         do_signoff = config.getboolean('options', 'signoff')
576     if not do_signoff and config.has_option(project_str, 'signoff'):
577         do_signoff = config.getboolean(project_str, 'signoff')
578
579     url = config.get(project_str, 'url')
580
581     (username, password) = (None, None)
582     transport = None
583     if action in auth_actions:
584         if config.has_option(project_str, 'username') and \
585                 config.has_option(project_str, 'password'):
586
587             use_https = url.startswith('https')
588
589             transport = BasicHTTPAuthTransport( \
590                     config.get(project_str, 'username'),
591                     config.get(project_str, 'password'),
592                     use_https)
593
594         else:
595             sys.stderr.write(("The %s action requires authentication, "
596                     "but no username or password\nis configured\n") % action)
597             sys.exit(1)
598
599     if project_str:
600         filt.add("project", project_str)
601
602     if state_str:
603         filt.add("state", state_str)
604
605     if msgid_str:
606         filt.add("msgid", msgid_str)
607
608     try:
609         rpc = xmlrpclib.Server(url, transport = transport)
610     except:
611         sys.stderr.write("Unable to connect to %s\n" % url)
612         sys.exit(1)
613
614     # It should be safe to assume hash_str is not zero, but who knows..
615     if hash_str != None:
616         patch_ids = [patch_id_from_hash(rpc, project_str, hash_str)]
617
618     # helper for non_empty() to print correct helptext
619     h = locals()[action + '_parser']
620
621     # Require either hash_str or IDs for
622     def non_empty(h, patch_ids):
623         """Error out if no patch IDs were specified"""
624         if patch_ids == None or len(patch_ids) < 1:
625             sys.stderr.write("Error: Missing Argument! " +
626               "Either [-h HASH] or [ID [ID ...]] are required\n")
627             if h:
628                 h.print_help()
629             sys.exit(1)
630         return patch_ids
631
632     if action == 'list' or action == 'search':
633         if args.get('patch_name') != None:
634             filt.add("name__icontains", args.get('patch_name'))
635         action_list(rpc, filt, submitter_str, delegate_str)
636
637     elif action.startswith('project'):
638         action_projects(rpc)
639
640     elif action.startswith('state'):
641         action_states(rpc)
642
643     elif action == 'view':
644         for patch_id in non_empty(h, patch_ids):
645             s = rpc.patch_get_mbox(patch_id)
646             if len(s) > 0:
647                 print unicode(s).encode("utf-8")
648
649     elif action in ('get', 'save', 'info'):
650         if action == 'info':
651             [action_info(rpc, patch_id) for patch_id in non_empty(h, patch_ids)]
652         else:
653             [action_get(rpc, patch_id) for patch_id in non_empty(h, patch_ids)]
654
655     elif action == 'apply':
656         [action_apply(rpc, patch_id) for patch_id in non_empty(h, patch_ids)]
657
658     elif action == 'git_am':
659         cmd = ['git', 'am']
660         if do_signoff:
661             cmd.append('-s')
662         [action_apply(rpc, patch_id, cmd) for patch_id in
663          non_empty(h, patch_ids)]
664
665     elif action == 'update':
666         [action_update_patch(rpc, patch_id, state = state_str,
667                 commit = commit_str
668          ) for patch_id in non_empty(h, patch_ids)]
669
670     else:
671         sys.stderr.write("Unknown action '%s'\n" % action)
672         action_parser.print_help()
673         sys.exit(1)
674
675 if __name__ == "__main__":
676     main()