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