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