]> git.ozlabs.org Git - patchwork/blob - apps/patchwork/bin/pwclient
a7afafc829cae407becb72dba178a1812209efa9
[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_x = hash_parser.add_mutually_exclusive_group(required=True)
335     hash_parser_x.add_argument(
336         '-h', metavar='HASH', dest='hash', action='store', required=False,
337         help='''Lookup by patch hash'''
338     )
339     hash_parser_x.add_argument(
340         'id', metavar='ID', nargs='?', action='store', type=int,
341         help='Patch ID',
342     )
343
344     filter_parser = argparse.ArgumentParser(add_help=False, version=False)
345     filter_parser.add_argument(
346         '-s', metavar='STATE',
347         help='''Filter by patch state (e.g., 'New', 'Accepted', etc.)'''
348     )
349     filter_parser.add_argument(
350         '-p', metavar='PROJECT',
351         help='''Filter by project name (see 'projects' for list)'''
352     )
353     filter_parser.add_argument(
354         '-w', metavar='WHO',
355         help='''Filter by submitter (name, e-mail substring search)'''
356     )
357     filter_parser.add_argument(
358         '-d', metavar='WHO',
359         help='''Filter by delegate (name, e-mail substring search)'''
360     )
361     filter_parser.add_argument(
362         '-n', metavar='MAX#',
363         type=int,
364         help='''Restrict number of results'''
365     )
366     filter_parser.add_argument(
367         '-m', metavar='MESSAGEID',
368         help='''Filter by Message-Id'''
369     )
370     filter_parser.add_argument(
371         'patch_name', metavar='STR', nargs='?',
372         help='substring to search for patches by name',
373     )
374
375     action_parser = argparse.ArgumentParser(
376         prog='pwclient',
377         add_help=False,
378         version=False,
379         formatter_class=argparse.RawDescriptionHelpFormatter,
380         epilog='''(apply | get | info | view | update) (-h HASH | ID)''',
381     )
382     action_parser.add_argument(
383         '--help',
384         #action='help',
385         action=_RecursiveHelpAction,
386         help='''Print this help text'''
387     )
388
389     subparsers = action_parser.add_subparsers(
390         title='Commands',
391         metavar=''
392     )
393     apply_parser = subparsers.add_parser(
394         'apply', parents=[hash_parser],
395         add_help=False,
396         help='''Apply a patch (in the current dir, using -p1)'''
397     )
398     apply_parser.set_defaults(subcmd='apply')
399     git_am_parser = subparsers.add_parser(
400         'git-am', parents=[hash_parser],
401         add_help=False,
402         help='''Apply a patch to current git branch using "git am".'''
403     )
404     git_am_parser.set_defaults(subcmd='git-am')
405     git_am_parser.add_argument(
406         '-s', '--signoff',
407         action='store_true',
408         help='''pass --signoff to git-am'''
409     )
410     get_parser = subparsers.add_parser(
411         'get', parents=[hash_parser],
412         add_help=False,
413         help='''Download a patch and save it locally'''
414     )
415     get_parser.set_defaults(subcmd='get')
416     info_parser = subparsers.add_parser(
417         'info', parents=[hash_parser],
418         add_help=False,
419         help='''Display patchwork info about a given patch ID'''
420     )
421     info_parser.set_defaults(subcmd='info')
422     projects_parser = subparsers.add_parser(
423         'projects',
424         add_help=False,
425         help='''List all projects'''
426     )
427     projects_parser.set_defaults(subcmd='projects')
428     states_parser = subparsers.add_parser(
429         'states',
430         add_help=False,
431         help='''Show list of potential patch states'''
432     )
433     states_parser.set_defaults(subcmd='states')
434     view_parser = subparsers.add_parser(
435         'view', parents=[hash_parser],
436         add_help=False,
437         help='''View a patch'''
438     )
439     view_parser.set_defaults(subcmd='view')
440     update_parser = subparsers.add_parser(
441         'update', parents=[hash_parser],
442         add_help=False,
443         help='''Update patch'''
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
478     # set defaults
479     filt = Filter()
480     submitter_str = ""
481     delegate_str = ""
482     project_str = ""
483     commit_str = ""
484     state_str = ""
485     hash_str = None
486     msgid_str = ""
487     id_str = None
488     url = DEFAULT_URL
489
490     action = args.get('subcmd')
491
492     if args.get('s'):
493         state_str = args.get('s')
494     if args.get('p'):
495         project_str = args.get('p')
496     if args.get('w'):
497         submitter_str = args.get('w')
498     if args.get('d'):
499         delegate_str = args.get('d')
500     if args.get('c'):
501         commit_str = args.get('c')
502     if args.get('hash'):
503         hash_str = args.get('hash')
504     if args.get('id'):
505         id_str = args.get('id')
506     if args.get('m'):
507         msgid_str = args.get('m')
508     if args.get('n') != None:
509         try:
510             filt.add("max_count", args.get('n'))
511         except:
512             sys.stderr.write("Invalid maximum count '%s'\n" % args.get('n'))
513             action_parser.print_help()
514             sys.exit(1)
515
516     do_signoff = args.get('signoff')
517
518     # grab settings from config files
519     config = ConfigParser.ConfigParser()
520     config.read([CONFIG_FILE])
521
522     if not config.has_section('options'):
523         sys.stderr.write('~/.pwclientrc is in the old format. Migrating it...')
524
525         old_project = config.get('base','project')
526
527         new_config = ConfigParser.ConfigParser()
528         new_config.add_section('options')
529
530         new_config.set('options','default',old_project)
531         new_config.add_section(old_project)
532
533         new_config.set(old_project,'url',config.get('base','url'))
534         if config.has_option('auth', 'username'):
535             new_config.set(old_project,'username',config.get('auth','username'))
536         if config.has_option('auth', 'password'):
537             new_config.set(old_project,'password',config.get('auth','password'))
538
539         old_config_file = CONFIG_FILE + '.orig'
540         shutil.copy2(CONFIG_FILE,old_config_file)
541
542         with open(CONFIG_FILE, 'wb') as fd:
543             new_config.write(fd)
544
545         sys.stderr.write(' Done.\n')
546         sys.stderr.write('Your old ~/.pwclientrc was saved to %s\n' % old_config_file)
547         sys.stderr.write('and was converted to the new format. You may want to\n')
548         sys.stderr.write('inspect it before continuing.\n')
549         sys.exit(1)
550
551     if not project_str:
552         try:
553             project_str = config.get('options', 'default')
554         except:
555             sys.stderr.write("No default project configured in ~/.pwclientrc\n")
556             action_parser.print_help()
557             sys.exit(1)
558
559     if not config.has_section(project_str):
560         sys.stderr.write("No section for project %s\n" % project_str)
561         sys.exit(1)
562
563     if not config.has_option(project_str, 'url'):
564         sys.stderr.write("No URL for project %s\n" % project_str)
565         sys.exit(1)
566     if not do_signoff and config.has_option('options', 'signoff'):
567         do_signoff = config.getboolean('options', 'signoff')
568     if not do_signoff and config.has_option(project_str, 'signoff'):
569         do_signoff = config.getboolean(project_str, 'signoff')
570
571     url = config.get(project_str, 'url')
572
573     (username, password) = (None, None)
574     transport = None
575     if action in auth_actions:
576         if config.has_option(project_str, 'username') and \
577                 config.has_option(project_str, 'password'):
578
579             use_https = url.startswith('https')
580
581             transport = BasicHTTPAuthTransport( \
582                     config.get(project_str, 'username'),
583                     config.get(project_str, 'password'),
584                     use_https)
585
586         else:
587             sys.stderr.write(("The %s action requires authentication, "
588                     "but no username or password\nis configured\n") % action)
589             sys.exit(1)
590
591     if project_str:
592         filt.add("project", project_str)
593
594     if state_str:
595         filt.add("state", state_str)
596
597     if msgid_str:
598         filt.add("msgid", msgid_str)
599
600     try:
601         rpc = xmlrpclib.Server(url, transport = transport)
602     except:
603         sys.stderr.write("Unable to connect to %s\n" % url)
604         sys.exit(1)
605
606     patch_id = None
607     # hash_str and id_str are mutually exclusive
608     if hash_str:
609         patch_id = patch_id_from_hash(rpc, project_str, hash_str)
610     else:
611         # id_str from argparse is an int
612         patch_id = id_str
613
614     if action == 'list' or action == 'search':
615         if args.get('patch_name') != None:
616             filt.add("name__icontains", args.get('patch_name'))
617         action_list(rpc, filt, submitter_str, delegate_str)
618
619     elif action.startswith('project'):
620         action_projects(rpc)
621
622     elif action.startswith('state'):
623         action_states(rpc)
624
625     elif action == 'view':
626         s = rpc.patch_get_mbox(patch_id)
627         if len(s) > 0:
628             print unicode(s).encode("utf-8")
629
630     elif action in ('get', 'save', 'info'):
631         if action == 'info':
632             action_info(rpc, patch_id)
633         else:
634             action_get(rpc, patch_id)
635
636     elif action == 'apply':
637         action_apply(rpc, patch_id)
638
639     elif action == 'git-am':
640         cmd = ['git', 'am']
641         if do_signoff:
642             cmd.append('-s')
643         action_apply(rpc, patch_id, cmd)
644
645     elif action == 'update':
646         action_update_patch(rpc, patch_id, state = state_str,
647                 commit = commit_str)
648
649     else:
650         sys.stderr.write("Unknown action '%s'\n" % action)
651         action_parser.print_help()
652         sys.exit(1)
653
654 if __name__ == "__main__":
655     main()