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