]> git.ozlabs.org Git - patchwork/blob - apps/patchwork/bin/pwclient
b91a4b7b2d52586772c4048c4744b8d930a26141
[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 usage():
110     sys.stderr.write("Usage: %s <action> [options]\n\n" % \
111                         (os.path.basename(sys.argv[0])))
112     sys.stderr.write("Where <action> is one of:\n")
113     sys.stderr.write(
114 """        apply <ID>    : Apply a patch (in the current dir, using -p1)
115         git-am <ID>   : Apply a patch to current git branch using "git am"
116         get <ID>      : Download a patch and save it locally
117         info <ID>     : Display patchwork info about a given patch ID
118         projects      : List all projects
119         states        : Show list of potential patch states
120         list [str]    : List patches, using the optional filters specified
121                         below and an optional substring to search for patches
122                         by name
123         search [str]  : Same as 'list'
124         view <ID>     : View a patch
125         update [-s state] [-c commit-ref] <ID>
126                       : Update patch\n""")
127     sys.stderr.write("""\nFilter options for 'list' and 'search':
128         -s <state>    : Filter by patch state (e.g., 'New', 'Accepted', etc.)
129         -p <project>  : Filter by project name (see 'projects' for list)
130         -w <who>      : Filter by submitter (name, e-mail substring search)
131         -d <who>      : Filter by delegate (name, e-mail substring search)
132         -n <max #>    : Restrict number of results
133         -m <messageid>: Filter by Message-Id\n""")
134     sys.stderr.write("""\nActions that take an ID argument can also be \
135 invoked with:
136         -h <hash>     : Lookup by patch hash\n""")
137     sys.exit(1)
138
139 def project_id_by_name(rpc, linkname):
140     """Given a project short name, look up the Project ID."""
141     if len(linkname) == 0:
142         return 0
143     projects = rpc.project_list(linkname, 0)
144     for project in projects:
145         if project['linkname'] == linkname:
146             return project['id']
147     return 0
148
149 def state_id_by_name(rpc, name):
150     """Given a partial state name, look up the state ID."""
151     if len(name) == 0:
152         return 0
153     states = rpc.state_list(name, 0)
154     for state in states:
155         if state['name'].lower().startswith(name.lower()):
156             return state['id']
157     return 0
158
159 def person_ids_by_name(rpc, name):
160     """Given a partial name or email address, return a list of the
161     person IDs that match."""
162     if len(name) == 0:
163         return []
164     people = rpc.person_list(name, 0)
165     return map(lambda x: x['id'], people)
166
167 def list_patches(patches):
168     """Dump a list of patches to stdout."""
169     print("%-7s %-12s %s" % ("ID", "State", "Name"))
170     print("%-7s %-12s %s" % ("--", "-----", "----"))
171     for patch in patches:
172         print("%-7d %-12s %s" % (patch['id'], patch['state'], patch['name']))
173
174 def action_list(rpc, filter, submitter_str, delegate_str):
175     filter.resolve_ids(rpc)
176
177     if submitter_str != "":
178         ids = person_ids_by_name(rpc, submitter_str)
179         if len(ids) == 0:
180             sys.stderr.write("Note: Nobody found matching *%s*\n" % \
181                              submitter_str)
182         else:
183             for id in ids:
184                 person = rpc.person_get(id)
185                 print "Patches submitted by %s <%s>:" % \
186                         (unicode(person['name']).encode("utf-8"), \
187                          unicode(person['email']).encode("utf-8"))
188                 f = filter
189                 f.add("submitter_id", id)
190                 patches = rpc.patch_list(f.d)
191                 list_patches(patches)
192         return
193
194     if delegate_str != "":
195         ids = person_ids_by_name(rpc, delegate_str)
196         if len(ids) == 0:
197             sys.stderr.write("Note: Nobody found matching *%s*\n" % \
198                              delegate_str)
199         else:
200             for id in ids:
201                 person = rpc.person_get(id)
202                 print "Patches delegated to %s <%s>:" % \
203                         (person['name'], person['email'])
204                 f = filter
205                 f.add("delegate_id", id)
206                 patches = rpc.patch_list(f.d)
207                 list_patches(patches)
208         return
209
210     patches = rpc.patch_list(filter.d)
211     list_patches(patches)
212
213 def action_projects(rpc):
214     projects = rpc.project_list("", 0)
215     print("%-5s %-24s %s" % ("ID", "Name", "Description"))
216     print("%-5s %-24s %s" % ("--", "----", "-----------"))
217     for project in projects:
218         print("%-5d %-24s %s" % (project['id'], \
219                 project['linkname'], \
220                 project['name']))
221
222 def action_states(rpc):
223     states = rpc.state_list("", 0)
224     print("%-5s %s" % ("ID", "Name"))
225     print("%-5s %s" % ("--", "----"))
226     for state in states:
227         print("%-5d %s" % (state['id'], state['name']))
228
229 def action_info(rpc, patch_id):
230     patch = rpc.patch_get(patch_id)
231     s = "Information for patch id %d" % (patch_id)
232     print(s)
233     print('-' * len(s))
234     for key, value in sorted(patch.iteritems()):
235         print("- %- 14s: %s" % (key, unicode(value).encode("utf-8")))
236
237 def action_get(rpc, patch_id):
238     patch = rpc.patch_get(patch_id)
239     s = rpc.patch_get_mbox(patch_id)
240
241     if patch == {} or len(s) == 0:
242         sys.stderr.write("Unable to get patch %d\n" % patch_id)
243         sys.exit(1)
244
245     base_fname = fname = os.path.basename(patch['filename'])
246     i = 0
247     while os.path.exists(fname):
248         fname = "%s.%d" % (base_fname, i)
249         i += 1
250
251     try:
252         f = open(fname, "w")
253     except:
254         sys.stderr.write("Unable to open %s for writing\n" % fname)
255         sys.exit(1)
256
257     try:
258         f.write(unicode(s).encode("utf-8"))
259         f.close()
260         print "Saved patch to %s" % fname
261     except:
262         sys.stderr.write("Failed to write to %s\n" % fname)
263         sys.exit(1)
264
265 def action_apply(rpc, patch_id, apply_cmd=None):
266     patch = rpc.patch_get(patch_id)
267     if patch == {}:
268         sys.stderr.write("Error getting information on patch ID %d\n" % \
269                          patch_id)
270         sys.exit(1)
271
272     if apply_cmd is None:
273       print "Applying patch #%d to current directory" % patch_id
274       apply_cmd = ['patch', '-p1']
275     else:
276       print "Applying patch #%d using %s" % (
277           patch_id, repr(' '.join(apply_cmd)))
278
279     print "Description: %s" % patch['name']
280     s = rpc.patch_get_mbox(patch_id)
281     if len(s) > 0:
282         proc = subprocess.Popen(apply_cmd, stdin = subprocess.PIPE)
283         proc.communicate(unicode(s).encode('utf-8'))
284     else:
285         sys.stderr.write("Error: No patch content found\n")
286         sys.exit(1)
287
288 def action_update_patch(rpc, patch_id, state = None, commit = None):
289     patch = rpc.patch_get(patch_id)
290     if patch == {}:
291         sys.stderr.write("Error getting information on patch ID %d\n" % \
292                          patch_id)
293         sys.exit(1)
294
295     params = {}
296
297     if state:
298         state_id = state_id_by_name(rpc, state)
299         if state_id == 0:
300             sys.stderr.write("Error: No State found matching %s*\n" % state)
301             sys.exit(1)
302         params['state'] = state_id
303
304     if commit:
305         params['commit_ref'] = commit
306
307     success = False
308     try:
309         success = rpc.patch_set(patch_id, params)
310     except xmlrpclib.Fault, f:
311         sys.stderr.write("Error updating patch: %s\n" % f.faultString)
312
313     if not success:
314         sys.stderr.write("Patch not updated\n")
315
316 def patch_id_from_hash(rpc, project, hash):
317     try:
318         patch = rpc.patch_get_by_project_hash(project, hash)
319     except xmlrpclib.Fault:
320         # the server may not have the newer patch_get_by_project_hash function,
321         # so fall back to hash-only.
322         patch = rpc.patch_get_by_hash(hash)
323
324     if patch == {}:
325         return None
326
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         for subparsers_action in subparsers_actions:
343             for choice, subparser in subparsers_action.choices.items():
344                 # gross but the whole thing is..
345                 if (len(subparser._actions) == 2 \
346                     and ['hash', 'id'] == [a.dest for a in subparser._actions])\
347                    or len(subparser._actions) == 0:
348                     continue
349                 print("command '{}'".format(choice))
350                 print(subparser.format_help())
351
352         parser.exit()
353
354 def main():
355     hash_parser = argparse.ArgumentParser(add_help=False, version=False)
356     hash_parser_x = hash_parser.add_mutually_exclusive_group(required=True)
357     hash_parser_x.add_argument(
358         '-h', metavar='HASH', dest='hash', action='store', required=False,
359         help='''Lookup by patch hash'''
360     )
361     hash_parser_x.add_argument(
362         'id', metavar='ID', nargs='?', action='store', type=int,
363         help='Patch ID',
364     )
365
366     filter_parser = argparse.ArgumentParser(add_help=False, version=False)
367     filter_parser.add_argument(
368         '-s', metavar='STATE',
369         help='''Filter by patch state (e.g., 'New', 'Accepted', etc.)'''
370     )
371     filter_parser.add_argument(
372         '-p', metavar='PROJECT',
373         help='''Filter by project name (see 'projects' for list)'''
374     )
375     filter_parser.add_argument(
376         '-w', metavar='WHO',
377         help='''Filter by submitter (name, e-mail substring search)'''
378     )
379     filter_parser.add_argument(
380         '-d', metavar='WHO',
381         help='''Filter by delegate (name, e-mail substring search)'''
382     )
383     filter_parser.add_argument(
384         '-n', metavar='MAX#',
385         type=int,
386         help='''Restrict number of results'''
387     )
388     filter_parser.add_argument(
389         '-m', metavar='MESSAGEID',
390         help='''Filter by Message-Id'''
391     )
392     filter_parser.add_argument(
393         'patch_name', metavar='STR', nargs='?',
394         help='substring to search for patches by name',
395     )
396
397     action_parser = argparse.ArgumentParser(
398         prog='pwclient',
399         add_help=False,
400         version=False,
401         formatter_class=argparse.RawDescriptionHelpFormatter,
402         epilog='''(apply | get | info | view | update) (-h HASH | ID)''',
403     )
404     action_parser.add_argument(
405         '--help',
406         #action='help',
407         action=_RecursiveHelpAction,
408         help='''Print this help text'''
409     )
410
411     subparsers = action_parser.add_subparsers(
412         title='Commands',
413         metavar=''
414     )
415     apply_parser = subparsers.add_parser(
416         'apply', parents=[hash_parser],
417         add_help=False,
418         help='''Apply a patch (in the current dir, using -p1)'''
419     )
420     git_am_parser = subparsers.add_parser(
421         'git-am', parents=[hash_parser],
422         add_help=False,
423         help='''Apply a patch to current git branch using "git am".'''
424     )
425     git_am_parser.add_argument(
426         '-s', '--signoff',
427         action='store_true',
428         help='''pass --signoff to git-am'''
429     )
430     get_parser = subparsers.add_parser(
431         'get', parents=[hash_parser],
432         add_help=False,
433         help='''Download a patch and save it locally'''
434     )
435     info_parser = subparsers.add_parser(
436         'info', parents=[hash_parser],
437         add_help=False,
438         help='''Display patchwork info about a given patch ID'''
439     )
440     projects_parser = subparsers.add_parser(
441         'projects',
442         add_help=False,
443         help='''List all projects'''
444     )
445     states_parser = subparsers.add_parser(
446         'states',
447         add_help=False,
448         help='''Show list of potential patch states'''
449     )
450     view_parser = subparsers.add_parser(
451         'view', parents=[hash_parser],
452         add_help=False,
453         help='''View a patch'''
454     )
455     update_parser = subparsers.add_parser(
456         'update', parents=[hash_parser],
457         add_help=False,
458         help='''Update patch'''
459     )
460     update_parser.add_argument(
461         '-c', metavar='COMMIT-REF',
462         help='''commit reference hash'''
463     )
464     update_parser.add_argument(
465         '-s', metavar='STATE',
466         required=True,
467         help='''Set patch state (e.g., 'Accepted', 'Superseded' etc.)'''
468     )
469
470     list_parser = subparsers.add_parser("list",
471         add_help=False,
472         #aliases=['search'],
473         parents=[filter_parser],
474         help='''List patches, using the optional filters specified
475         below and an optional substring to search for patches
476         by name'''
477     )
478     search_parser = subparsers.add_parser("search",
479         add_help=False,
480         parents=[filter_parser],
481         help='''Alias for "list"'''
482     )
483     if len(sys.argv) < 2:
484         action_parser.print_help()
485         sys.exit(0)
486
487     args = action_parser.parse_args()
488
489     action = sys.argv[1].lower()
490
491     # set defaults
492     filt = Filter()
493     submitter_str = ""
494     delegate_str = ""
495     project_str = ""
496     commit_str = ""
497     state_str = ""
498     hash_str = ""
499     msgid_str = ""
500     id_str = None
501     url = DEFAULT_URL
502
503     args=dict(vars(args))
504     if args.get('s'):
505         state_str = args.get('s')
506     if args.get('p'):
507         project_str = args.get('p')
508     if args.get('w'):
509         submitter_str = args.get('w')
510     if args.get('d'):
511         delegate_str = args.get('d')
512     if args.get('c'):
513         commit_str = args.get('c')
514     if args.get('hash'):
515         hash_str = args.get('hash')
516     if args.get('id'):
517         id_str = args.get('id')
518     if args.get('m'):
519         msgid_str = args.get('m')
520     if args.get('n') != None:
521         try:
522             filt.add("max_count", args.get('n'))
523         except:
524             sys.stderr.write("Invalid maximum count '%s'\n" % args.get('n'))
525             action_parser.print_help()
526             sys.exit(1)
527
528     # grab settings from config files
529     config = ConfigParser.ConfigParser()
530     config.read([CONFIG_FILE])
531
532     if not config.has_section('options'):
533         sys.stderr.write('~/.pwclientrc is in the old format. Migrating it...')
534
535         old_project = config.get('base','project')
536
537         new_config = ConfigParser.ConfigParser()
538         new_config.add_section('options')
539
540         new_config.set('options','default',old_project)
541         new_config.add_section(old_project)
542
543         new_config.set(old_project,'url',config.get('base','url'))
544         if config.has_option('auth', 'username'):
545             new_config.set(old_project,'username',config.get('auth','username'))
546         if config.has_option('auth', 'password'):
547             new_config.set(old_project,'password',config.get('auth','password'))
548
549         old_config_file = CONFIG_FILE + '.orig'
550         shutil.copy2(CONFIG_FILE,old_config_file)
551
552         with open(CONFIG_FILE, 'wb') as fd:
553             new_config.write(fd)
554
555         sys.stderr.write(' Done.\n')
556         sys.stderr.write('Your old ~/.pwclientrc was saved to %s\n' % old_config_file)
557         sys.stderr.write('and was converted to the new format. You may want to\n')
558         sys.stderr.write('inspect it before continuing.\n')
559         sys.exit(1)
560
561     if not project_str:
562         try:
563             project_str = config.get('options', 'default')
564         except:
565             sys.stderr.write("No default project configured in ~/.pwclientrc\n")
566             usage()
567
568     if not config.has_section(project_str):
569         sys.stderr.write("No section for project %s\n" % project_str)
570         sys.exit(1)
571
572     if not config.has_option(project_str, 'url'):
573         sys.stderr.write("No URL for project %s\n" % project_str)
574         sys.exit(1)
575
576     url = config.get(project_str, 'url')
577
578     (username, password) = (None, None)
579     transport = None
580     if action in auth_actions:
581         if config.has_option(project_str, 'username') and \
582                 config.has_option(project_str, 'password'):
583
584             use_https = url.startswith('https')
585
586             transport = BasicHTTPAuthTransport( \
587                     config.get(project_str, 'username'),
588                     config.get(project_str, 'password'),
589                     use_https)
590
591         else:
592             sys.stderr.write(("The %s action requires authentication, "
593                     "but no username or password\nis configured\n") % action)
594             sys.exit(1)
595
596     if project_str:
597         filt.add("project", project_str)
598
599     if state_str:
600         filt.add("state", state_str)
601
602     if msgid_str:
603         filt.add("msgid", msgid_str)
604
605     try:
606         rpc = xmlrpclib.Server(url, transport = transport)
607     except:
608         sys.stderr.write("Unable to connect to %s\n" % url)
609         sys.exit(1)
610
611     patch_id = None
612     if hash_str:
613         patch_id = patch_id_from_hash(rpc, project_str, hash_str)
614         if patch_id is None:
615             sys.stderr.write("No patch has the hash provided\n")
616             sys.exit(1)
617
618     if action == 'list' or action == 'search':
619         if args.get('patch_name') != None:
620             filt.add("name__icontains", args.get('patch_name'))
621         action_list(rpc, filt, submitter_str, delegate_str)
622
623     elif action.startswith('project'):
624         action_projects(rpc)
625
626     elif action.startswith('state'):
627         action_states(rpc)
628
629     elif action == 'view':
630         try:
631             patch_id = patch_id or int(id_str)
632         except:
633             sys.stderr.write("Invalid patch ID given\n")
634             sys.exit(1)
635
636         s = rpc.patch_get_mbox(patch_id)
637         if len(s) > 0:
638             print unicode(s).encode("utf-8")
639
640     elif action in ('get', 'save', 'info'):
641         try:
642             patch_id = patch_id or int(id_str)
643         except:
644             sys.stderr.write("Invalid patch ID given\n")
645             sys.exit(1)
646
647         if action == 'info':
648             action_info(rpc, patch_id)
649         else:
650             action_get(rpc, patch_id)
651
652     elif action == 'apply':
653         try:
654             patch_id = patch_id or int(id_str)
655         except:
656             sys.stderr.write("Invalid patch ID given\n")
657             sys.exit(1)
658
659         action_apply(rpc, patch_id)
660
661     elif action == 'git-am':
662         try:
663             patch_id = patch_id or int(id_str)
664         except:
665             sys.stderr.write("Invalid patch ID given\n")
666             sys.exit(1)
667
668         cmd = ['git', 'am']
669         if args.get('signoff'):
670             cmd.append('-s')
671         action_apply(rpc, patch_id, cmd)
672
673     elif action == 'update':
674         try:
675             patch_id = patch_id or int(id_str)
676         except:
677             sys.stderr.write("Invalid patch ID given\n")
678             sys.exit(1)
679
680         action_update_patch(rpc, patch_id, state = state_str,
681                 commit = commit_str)
682
683     else:
684         sys.stderr.write("Unknown action '%s'\n" % action)
685         usage()
686
687 if __name__ == "__main__":
688     main()