]> git.ozlabs.org Git - patchwork/blob - apps/patchwork/bin/pwclient
2104f593f9ca37618d33cc4bee0530366aaea76a
[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 getopt
26 import string
27 import tempfile
28 import subprocess
29 import base64
30 import ConfigParser
31
32 # Default Patchwork remote XML-RPC server URL
33 # This script will check the PW_XMLRPC_URL environment variable
34 # for the URL to access.  If that is unspecified, it will fallback to
35 # the hardcoded default value specified here.
36 DEFAULT_URL = "http://patchwork/xmlrpc/"
37 CONFIG_FILE = os.path.expanduser('~/.pwclientrc')
38
39 class Filter:
40     """Filter for selecting patches."""
41     def __init__(self):
42         # These fields refer to specific objects, so they are special
43         # because we have to resolve them to IDs before passing the
44         # filter to the server
45         self.state = ""
46         self.project = ""
47
48         # The dictionary that gets passed to via XML-RPC
49         self.d = {}
50
51     def add(self, field, value):
52         if field == 'state':
53             self.state = value
54         elif field == 'project':
55             self.project = value
56         else:
57             # OK to add directly
58             self.d[field] = value
59
60     def resolve_ids(self, rpc):
61         """Resolve State, Project, and Person IDs based on filter strings."""
62         if self.state != "":
63             id = state_id_by_name(rpc, self.state)
64             if id == 0:
65                 sys.stderr.write("Note: No State found matching %s*, " \
66                                  "ignoring filter\n" % self.state)
67             else:
68                 self.d['state_id'] = id
69
70         if self.project != "":
71             id = project_id_by_name(rpc, self.project)
72             if id == 0:
73                 sys.stderr.write("Note: No Project found matching %s, " \
74                                  "ignoring filter\n" % self.project)
75             else:
76                 self.d['project_id'] = id
77
78     def __str__(self):
79         """Return human-readable description of the filter."""
80         return str(self.d)
81
82 class BasicHTTPAuthTransport(xmlrpclib.SafeTransport):
83
84     def __init__(self, username = None, password = None, use_https = False):
85         self.username = username
86         self.password = password
87         self.use_https = use_https
88         xmlrpclib.SafeTransport.__init__(self)
89
90     def authenticated(self):
91         return self.username != None and self.password != None
92
93     def send_host(self, connection, host):
94         xmlrpclib.Transport.send_host(self, connection, host)
95         if not self.authenticated():
96             return
97         credentials = '%s:%s' % (self.username, self.password)
98         auth = 'Basic ' + base64.encodestring(credentials).strip()
99         connection.putheader('Authorization', auth)
100
101     def make_connection(self, host):
102         if self.use_https:
103             fn = xmlrpclib.SafeTransport.make_connection
104         else:
105             fn = xmlrpclib.Transport.make_connection
106         return fn(self, host)
107
108 def usage():
109     sys.stderr.write("Usage: %s <action> [options]\n\n" % \
110                         (os.path.basename(sys.argv[0])))
111     sys.stderr.write("Where <action> is one of:\n")
112     sys.stderr.write(
113 """        apply <ID>    : Apply a patch (in the current dir, using -p1)
114         git-am <ID>   : Apply a patch to current git branch using "git am"
115         get <ID>      : Download a patch and save it locally
116         info <ID>     : Display patchwork info about a given patch ID
117         projects      : List all projects
118         states        : Show list of potential patch states
119         list [str]    : List patches, using the optional filters specified
120                         below and an optional substring to search for patches
121                         by name
122         search [str]  : Same as 'list'
123         view <ID>     : View a patch
124         update [-s state] [-c commit-ref] <ID>
125                       : Update patch\n""")
126     sys.stderr.write("""\nFilter options for 'list' and 'search':
127         -s <state>    : Filter by patch state (e.g., 'New', 'Accepted', etc.)
128         -p <project>  : Filter by project name (see 'projects' for list)
129         -w <who>      : Filter by submitter (name, e-mail substring search)
130         -d <who>      : Filter by delegate (name, e-mail substring search)
131         -n <max #>    : Restrict number of results
132         -m <messageid>: Filter by Message-Id\n""")
133     sys.stderr.write("""\nActions that take an ID argument can also be \
134 invoked with:
135         -h <hash>     : Lookup by patch hash\n""")
136     sys.exit(1)
137
138 def project_id_by_name(rpc, linkname):
139     """Given a project short name, look up the Project ID."""
140     if len(linkname) == 0:
141         return 0
142     projects = rpc.project_list(linkname, 0)
143     for project in projects:
144         if project['linkname'] == linkname:
145             return project['id']
146     return 0
147
148 def state_id_by_name(rpc, name):
149     """Given a partial state name, look up the state ID."""
150     if len(name) == 0:
151         return 0
152     states = rpc.state_list(name, 0)
153     for state in states:
154         if state['name'].lower().startswith(name.lower()):
155             return state['id']
156     return 0
157
158 def person_ids_by_name(rpc, name):
159     """Given a partial name or email address, return a list of the
160     person IDs that match."""
161     if len(name) == 0:
162         return []
163     people = rpc.person_list(name, 0)
164     return map(lambda x: x['id'], people)
165
166 def list_patches(patches):
167     """Dump a list of patches to stdout."""
168     print("%-7s %-12s %s" % ("ID", "State", "Name"))
169     print("%-7s %-12s %s" % ("--", "-----", "----"))
170     for patch in patches:
171         print("%-7d %-12s %s" % (patch['id'], patch['state'], patch['name']))
172
173 def action_list(rpc, filter, submitter_str, delegate_str):
174     filter.resolve_ids(rpc)
175
176     if submitter_str != "":
177         ids = person_ids_by_name(rpc, submitter_str)
178         if len(ids) == 0:
179             sys.stderr.write("Note: Nobody found matching *%s*\n" % \
180                              submitter_str)
181         else:
182             for id in ids:
183                 person = rpc.person_get(id)
184                 print "Patches submitted by %s <%s>:" % \
185                         (unicode(person['name']).encode("utf-8"), \
186                          unicode(person['email']).encode("utf-8"))
187                 f = filter
188                 f.add("submitter_id", id)
189                 patches = rpc.patch_list(f.d)
190                 list_patches(patches)
191         return
192
193     if delegate_str != "":
194         ids = person_ids_by_name(rpc, delegate_str)
195         if len(ids) == 0:
196             sys.stderr.write("Note: Nobody found matching *%s*\n" % \
197                              delegate_str)
198         else:
199             for id in ids:
200                 person = rpc.person_get(id)
201                 print "Patches delegated to %s <%s>:" % \
202                         (person['name'], person['email'])
203                 f = filter
204                 f.add("delegate_id", id)
205                 patches = rpc.patch_list(f.d)
206                 list_patches(patches)
207         return
208
209     patches = rpc.patch_list(filter.d)
210     list_patches(patches)
211
212 def action_projects(rpc):
213     projects = rpc.project_list("", 0)
214     print("%-5s %-24s %s" % ("ID", "Name", "Description"))
215     print("%-5s %-24s %s" % ("--", "----", "-----------"))
216     for project in projects:
217         print("%-5d %-24s %s" % (project['id'], \
218                 project['linkname'], \
219                 project['name']))
220
221 def action_states(rpc):
222     states = rpc.state_list("", 0)
223     print("%-5s %s" % ("ID", "Name"))
224     print("%-5s %s" % ("--", "----"))
225     for state in states:
226         print("%-5d %s" % (state['id'], state['name']))
227
228 def action_info(rpc, patch_id):
229     patch = rpc.patch_get(patch_id)
230     s = "Information for patch id %d" % (patch_id)
231     print(s)
232     print('-' * len(s))
233     for key, value in sorted(patch.iteritems()):
234         print("- %- 14s: %s" % (key, unicode(value).encode("utf-8")))
235
236 def action_get(rpc, patch_id):
237     patch = rpc.patch_get(patch_id)
238     s = rpc.patch_get_mbox(patch_id)
239
240     if patch == {} or len(s) == 0:
241         sys.stderr.write("Unable to get patch %d\n" % patch_id)
242         sys.exit(1)
243
244     base_fname = fname = os.path.basename(patch['filename'])
245     i = 0
246     while os.path.exists(fname):
247         fname = "%s.%d" % (base_fname, i)
248         i += 1
249
250     try:
251         f = open(fname, "w")
252     except:
253         sys.stderr.write("Unable to open %s for writing\n" % fname)
254         sys.exit(1)
255
256     try:
257         f.write(unicode(s).encode("utf-8"))
258         f.close()
259         print "Saved patch to %s" % fname
260     except:
261         sys.stderr.write("Failed to write to %s\n" % fname)
262         sys.exit(1)
263
264 def action_apply(rpc, patch_id, apply_cmd=None):
265     patch = rpc.patch_get(patch_id)
266     if patch == {}:
267         sys.stderr.write("Error getting information on patch ID %d\n" % \
268                          patch_id)
269         sys.exit(1)
270
271     if apply_cmd is None:
272       print "Applying patch #%d to current directory" % patch_id
273       apply_cmd = ['patch', '-p1']
274     else:
275       print "Applying patch #%d using %s" % (
276           patch_id, repr(' '.join(apply_cmd)))
277
278     print "Description: %s" % patch['name']
279     s = rpc.patch_get_mbox(patch_id)
280     if len(s) > 0:
281         proc = subprocess.Popen(apply_cmd, stdin = subprocess.PIPE)
282         proc.communicate(unicode(s).encode('utf-8'))
283     else:
284         sys.stderr.write("Error: No patch content found\n")
285         sys.exit(1)
286
287 def action_update_patch(rpc, patch_id, state = None, commit = None):
288     patch = rpc.patch_get(patch_id)
289     if patch == {}:
290         sys.stderr.write("Error getting information on patch ID %d\n" % \
291                          patch_id)
292         sys.exit(1)
293
294     params = {}
295
296     if state:
297         state_id = state_id_by_name(rpc, state)
298         if state_id == 0:
299             sys.stderr.write("Error: No State found matching %s*\n" % state)
300             sys.exit(1)
301         params['state'] = state_id
302
303     if commit:
304         params['commit_ref'] = commit
305
306     success = False
307     try:
308         success = rpc.patch_set(patch_id, params)
309     except xmlrpclib.Fault, f:
310         sys.stderr.write("Error updating patch: %s\n" % f.faultString)
311
312     if not success:
313         sys.stderr.write("Patch not updated\n")
314
315 def patch_id_from_hash(rpc, project, hash):
316     try:
317         patch = rpc.patch_get_by_project_hash(project, hash)
318     except xmlrpclib.Fault:
319         # the server may not have the newer patch_get_by_project_hash function,
320         # so fall back to hash-only.
321         patch = rpc.patch_get_by_hash(hash)
322
323     if patch == {}:
324         return None
325
326     return patch['id']
327
328 auth_actions = ['update']
329
330 def main():
331     try:
332         opts, args = getopt.getopt(sys.argv[2:], 's:p:w:d:n:c:h:m:')
333     except getopt.GetoptError, err:
334         print str(err)
335         usage()
336
337     if len(sys.argv) < 2:
338         usage()
339
340     action = sys.argv[1].lower()
341
342     # set defaults
343     filt = Filter()
344     submitter_str = ""
345     delegate_str = ""
346     project_str = ""
347     commit_str = ""
348     state_str = ""
349     hash_str = ""
350     msgid_str = ""
351     url = DEFAULT_URL
352
353     config = ConfigParser.ConfigParser()
354     config.read([CONFIG_FILE])
355
356     # grab settings from config files
357     if config.has_option('base', 'url'):
358         url = config.get('base', 'url')
359
360     if config.has_option('base', 'project'):
361         project_str = config.get('base', 'project')
362
363     for name, value in opts:
364         if name == '-s':
365             state_str = value
366         elif name == '-p':
367             project_str = value
368         elif name == '-w':
369             submitter_str = value
370         elif name == '-d':
371             delegate_str = value
372         elif name == '-c':
373             commit_str = value
374         elif name == '-h':
375             hash_str = value
376         elif name == '-m':
377             msgid_str = value
378         elif name == '-n':
379             try:
380                 filt.add("max_count", int(value))
381             except:
382                 sys.stderr.write("Invalid maximum count '%s'\n" % value)
383                 usage()
384         else:
385             sys.stderr.write("Unknown option '%s'\n" % name)
386             usage()
387
388     if len(args) > 1:
389         sys.stderr.write("Too many arguments specified\n")
390         usage()
391
392     (username, password) = (None, None)
393     transport = None
394     if action in auth_actions:
395         if config.has_option('auth', 'username') and \
396                 config.has_option('auth', 'password'):
397
398             use_https = url.startswith('https')
399
400             transport = BasicHTTPAuthTransport( \
401                     config.get('auth', 'username'),
402                     config.get('auth', 'password'),
403                     use_https)
404
405         else:
406             sys.stderr.write(("The %s action requires authentication, "
407                     "but no username or password\nis configured\n") % action)
408             sys.exit(1)
409
410     if project_str:
411         filt.add("project", project_str)
412
413     if state_str:
414         filt.add("state", state_str)
415
416     if msgid_str:
417         filt.add("msgid", msgid_str)
418
419     try:
420         rpc = xmlrpclib.Server(url, transport = transport)
421     except:
422         sys.stderr.write("Unable to connect to %s\n" % url)
423         sys.exit(1)
424
425     patch_id = None
426     if hash_str:
427         patch_id = patch_id_from_hash(rpc, project_str, hash_str)
428         if patch_id is None:
429             sys.stderr.write("No patch has the hash provided\n")
430             sys.exit(1)
431
432
433     if action == 'list' or action == 'search':
434         if len(args) > 0:
435             filt.add("name__icontains", args[0])
436         action_list(rpc, filt, submitter_str, delegate_str)
437
438     elif action.startswith('project'):
439         action_projects(rpc)
440
441     elif action.startswith('state'):
442         action_states(rpc)
443
444     elif action == 'view':
445         try:
446             patch_id = patch_id or int(args[0])
447         except:
448             sys.stderr.write("Invalid patch ID given\n")
449             sys.exit(1)
450
451         s = rpc.patch_get_mbox(patch_id)
452         if len(s) > 0:
453             print unicode(s).encode("utf-8")
454
455     elif action in ('get', 'save', 'info'):
456         try:
457             patch_id = patch_id or int(args[0])
458         except:
459             sys.stderr.write("Invalid patch ID given\n")
460             sys.exit(1)
461
462         if action == 'info':
463             action_info(rpc, patch_id)
464         else:
465             action_get(rpc, patch_id)
466
467     elif action == 'apply':
468         try:
469             patch_id = patch_id or int(args[0])
470         except:
471             sys.stderr.write("Invalid patch ID given\n")
472             sys.exit(1)
473
474         action_apply(rpc, patch_id)
475
476     elif action == 'git-am':
477         try:
478             patch_id = patch_id or int(args[0])
479         except:
480             sys.stderr.write("Invalid patch ID given\n")
481             sys.exit(1)
482
483         action_apply(rpc, patch_id, ['git', 'am'])
484
485     elif action == 'update':
486         try:
487             patch_id = patch_id or int(args[0])
488         except:
489             sys.stderr.write("Invalid patch ID given\n")
490             sys.exit(1)
491
492         action_update_patch(rpc, patch_id, state = state_str,
493                 commit = commit_str)
494
495     else:
496         sys.stderr.write("Unknown action '%s'\n" % action)
497         usage()
498
499 if __name__ == "__main__":
500     main()