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