Renamed Batch to MultiCall, added check_for_errors so that we get a _last_response...
[invirt/packages/python-jsonrpclib.git] / jsonrpclib.py
1 """
2 JSONRPCLIB -- started by Josh Marshall
3
4 This library is a JSON-RPC v.2 (proposed) implementation which
5 follows the xmlrpclib API for portability between clients. It
6 uses the same Server / ServerProxy, loads, dumps, etc. syntax,
7 while providing features not present in XML-RPC like:
8
9 * Keyword arguments
10 * Notifications
11 * Versioning
12 * Batches and batch notifications
13
14 Eventually, I'll add a SimpleXMLRPCServer compatible library,
15 and other things to tie the thing off nicely. :)
16
17 For a quick-start, just open a console and type the following,
18 replacing the server address, method, and parameters 
19 appropriately.
20 >>> import jsonrpclib
21 >>> server = jsonrpclib.Server('http://localhost:8181')
22 >>> server.add(5, 6)
23 11
24 >>> jsonrpclib.__notify('add', (5, 6))
25
26 See http://code.google.com/p/jsonrpclib/ for more info.
27 """
28
29 import types
30 import sys
31 from xmlrpclib import Transport as XMLTransport
32 from xmlrpclib import SafeTransport as XMLSafeTransport
33 from xmlrpclib import ServerProxy as XMLServerProxy
34 from xmlrpclib import _Method as XML_Method
35 import time
36
37 # JSON library importing
38 cjson = None
39 json = None
40 try:
41     import cjson
42 except ImportError:
43     pass
44 if not cjson:
45     try:
46         import json
47     except ImportError:
48         pass
49 if not cjson and not json: 
50     try:
51         import simplejson as json
52     except ImportError:
53         raise ImportError('You must have the cjson, json, or simplejson ' +
54                           'module(s) available.')
55
56 # Library attributes
57 _version = 2.0
58 _last_response = None
59 _last_request = None
60 _user_agent = 'jsonrpclib/0.1 (Python %s)' % \
61     '.'.join([str(ver) for ver in sys.version_info[0:3]])
62
63 #JSON Abstractions
64
65 def jdumps(obj, encoding='utf-8'):
66     # Do 'serialize' test at some point for other classes
67     global cjson
68     if cjson:
69         return cjson.encode(obj)
70     else:
71         return json.dumps(obj, encoding=encoding)
72
73 def jloads(json_string):
74     global cjson
75     if cjson:
76         return cjson.decode(json_string)
77     else:
78         return json.loads(json_string)
79
80
81 # XMLRPClib re-implemntations
82
83 class ProtocolError(Exception):
84     pass
85
86 class Transport(XMLTransport):
87     """ Just extends the XMLRPC transport where necessary. """
88     user_agent = _user_agent
89
90     def send_content(self, connection, request_body):
91         connection.putheader("Content-Type", "text/json")
92         connection.putheader("Content-Length", str(len(request_body)))
93         connection.endheaders()
94         if request_body:
95             connection.send(request_body)
96
97     def _parse_response(self, file_h, sock):
98         response_body = ''
99         while 1:
100             if sock:
101                 response = sock.recv(1024)
102             else:
103                 response = file_h.read(1024)
104             if not response:
105                 break
106             if self.verbose:
107                 print 'body: %s' % response
108             response_body += response
109         return_obj = loads(response_body)
110         return return_obj
111
112 class SafeTransport(XMLSafeTransport):
113     """ Just extends for HTTPS calls """
114     user_agent = Transport.user_agent
115     send_content = Transport.send_content
116     _parse_response = Transport._parse_response
117
118 class ServerProxy(XMLServerProxy):
119     """
120     Unfortunately, much more of this class has to be copied since
121     so much of it does the serialization.
122     """
123
124     def __init__(self, uri, transport=None, encoding=None, 
125                  verbose=0, version=None):
126         import urllib
127         global _version
128         if not version:
129             version = _version
130         self.__version = version
131         schema, uri = urllib.splittype(uri)
132         if schema not in ('http', 'https'):
133             raise IOError('Unsupported JSON-RPC protocol.')
134         self.__host, self.__handler = urllib.splithost(uri)
135         if not self.__handler:
136             # Not sure if this is in the JSON spec?
137             self.__handler = '/RPC2'
138         if transport is None:
139             if schema == 'https':
140                 transport = SafeTransport()
141             else:
142                 transport = Transport()
143         self.__transport = transport
144         self.__encoding = encoding
145         self.__verbose = verbose
146
147     def __request(self, methodname, params, rpcid=None):
148         request = dumps(params, methodname, encoding=self.__encoding,
149                         rpcid=rpcid, version=self.__version)
150         response = self.__run_request(request)
151         return response['result']
152     
153     def __notify(self, methodname, params, rpcid=None):
154         request = dumps(params, methodname, encoding=self.__encoding,
155                         rpcid=rpcid, version=self.__version, notify=True)
156         response = self.__run_request(request, notify=True)
157         return
158
159     def __run_request(self, request, notify=None):
160         global _last_request
161         global _last_response
162         _last_request = request
163         
164         if notify is True:
165             _last_response = None
166             return None
167
168         response = self.__transport.request(
169             self.__host,
170             self.__handler,
171             request,
172             verbose=self.__verbose
173         )
174         
175         # Here, the XMLRPC library translates a single list
176         # response to the single value -- should we do the
177         # same, and require a tuple / list to be passed to
178         # the response object, or expect the Server to be 
179         # outputting the response appropriately?
180         
181         _last_response = response
182         return check_for_errors(response)
183
184     def __getattr__(self, name):
185         # Same as original, just with new _Method and wrapper 
186         # for __notify
187         if name in ('__notify', '__run_request'):
188             wrapped_name = '_%s%s' % (self.__class__.__name__, name)
189             return getattr(self, wrapped_name)
190         return _Method(self.__request, name)
191
192 class _Method(XML_Method):
193     def __call__(self, *args, **kwargs):
194         if len(args) > 0 and len(kwargs) > 0:
195             raise ProtocolError('Cannot use both positional ' +
196                 'and keyword arguments (according to JSON-RPC spec.)')
197         if len(args) > 0:
198             return self.__send(self.__name, args)
199         else:
200             return self.__send(self.__name, kwargs)
201
202 # Batch implementation
203
204 class Job(object):
205     
206     def __init__(self, method, notify=False):
207         self.method = method
208         self.params = []
209         self.notify = notify
210
211     def __call__(self, *args, **kwargs):
212         if len(kwargs) > 0 and len(args) > 0:
213             raise ProtocolError('A Job cannot have both positional ' +
214                                 'and keyword arguments.')
215         if len(kwargs) > 0:
216             self.params = kwargs
217         else:
218             self.params = args
219
220     def request(self, encoding=None, rpcid=None):
221         return dumps(self.params, self.method, version=2.0,
222                      encoding=encoding, rpcid=rpcid, notify=self.notify)
223
224     def __repr__(self):
225         return '%s' % self.request()
226
227 class MultiCall(ServerProxy):
228     
229     def __init__(self, uri, *args, **kwargs):
230         self.__job_list = []
231         ServerProxy.__init__(self, uri, *args, **kwargs)
232
233     def __run_request(self, request_body):
234         run_request = getattr(ServerProxy, '_ServerProxy__run_request')
235         return run_request(self, request_body)
236
237     def __request(self):
238         if len(self.__job_list) < 1:
239             # Should we alert? This /is/ pretty obvious.
240             return
241         request_body = '[ %s ]' % ','.join([job.request() for
242                                           job in self.__job_list])
243         responses = self.__run_request(request_body)
244         del self.__job_list[:]
245         return [ response['result'] for response in responses ]
246
247     def __notify(self, method, params):
248         new_job = Job(method, notify=True)
249         self.__job_list.append(new_job)
250
251     def __getattr__(self, name):
252         if name in ('__run', '__notify'):
253             wrapped_name = '_%s%s' % (self.__class__.__name__, name)
254             return getattr(self, wrapped_name)
255         new_job = Job(name)
256         self.__job_list.append(new_job)
257         return new_job
258
259     __call__ = __request
260
261 # These lines conform to xmlrpclib's "compatibility" line. 
262 # Not really sure if we should include these, but oh well.
263 Server = ServerProxy
264
265 class Fault(dict):
266     # JSON-RPC error class
267     def __init__(self, code=-32000, message='Server error'):
268         self.faultCode = code
269         self.faultString = message
270
271     def error(self):
272         return {'code':self.faultCode, 'message':self.faultString}
273
274     def response(self, rpcid=None, version=None):
275         global _version
276         if not version:
277             version = _version
278         return dumps(self, rpcid=None, methodresponse=True,
279                      version=version)
280
281 def random_id(length=8):
282     import string
283     import random
284     random.seed()
285     choices = string.lowercase+string.digits
286     return_id = ''
287     for i in range(length):
288         return_id += random.choice(choices)
289     return return_id
290
291 class Payload(dict):
292     def __init__(self, rpcid=None, version=None):
293         global _version
294         if not version:
295             version = _version
296         self.id = rpcid
297         self.version = float(version)
298     
299     def request(self, method, params=[]):
300         if type(method) not in types.StringTypes:
301             raise ValueError('Method name must be a string.')
302         if not self.id:
303             self.id = random_id()
304         request = {'id':self.id, 'method':method, 'params':params}
305         if self.version >= 2:
306             request['jsonrpc'] = str(self.version)
307         return request
308
309     def notify(self, method, params=[]):
310         request = self.request(method, params)
311         if self.version >= 2:
312             del request['id']
313         else:
314             request['id'] = None
315         return request
316
317     def response(self, result=None):
318         response = {'result':result, 'id':self.id}
319         if self.version >= 2:
320             response['jsonrpc'] = str(self.version)
321         else:
322             response['error'] = None
323         return response
324
325     def error(self, code=-32000, message='Server error.'):
326         error = self.response()
327         if self.version >= 2:
328             del error['result']
329         else:
330             error['result'] = None
331         error['error'] = {'code':code, 'message':message}
332         return error
333
334 def dumps(params=[], methodname=None, methodresponse=None, 
335         encoding=None, rpcid=None, version=None, notify=None):
336     """
337     This differs from the Python implementation in that it implements 
338     the rpcid argument since the 2.0 spec requires it for responses.
339     """
340     global _version
341     if not version:
342         verion = _version
343     valid_params = (types.TupleType, types.ListType, types.DictType)
344     if methodname in types.StringTypes and \
345             type(params) not in valid_params and \
346             not isinstance(params, Fault):
347         """ 
348         If a method, and params are not in a listish or a Fault,
349         error out.
350         """
351         raise TypeError('Params must be a dict, list, tuple or Fault ' +
352                         'instance.')
353     if type(methodname) not in types.StringTypes and methodresponse != True:
354         raise ValueError('Method name must be a string, or methodresponse '+
355                          'must be set to True.')
356     if isinstance(params, Fault) and not methodresponse:
357         raise TypeError('You can only use a Fault for responses.')
358     # Begin parsing object
359     payload = Payload(rpcid=rpcid, version=version)
360     if not encoding:
361         encoding = 'utf-8'
362     if type(params) is Fault:
363         response = payload.error(params.faultCode, params.faultString)
364         return jdumps(response, encoding=encoding)
365     if methodresponse is True:
366         if rpcid is None:
367             raise ValueError('A method response must have an rpcid.')
368         response = payload.response(params)
369         return jdumps(response, encoding=encoding)
370     request = None
371     if notify == True:
372         request = payload.notify(methodname, params)
373     else:
374         request = payload.request(methodname, params)
375     return jdumps(request, encoding=encoding)
376
377 def loads(data):
378     """
379     This differs from the Python implementation, in that it returns
380     the request structure in Dict format instead of the method, params.
381     It will return a list in the case of a batch request / response.
382     """
383     result = jloads(data)
384     # if the above raises an error, the implementing server code 
385     # should return something like the following:
386     # { 'jsonrpc':'2.0', 'error': fault.error(), id: None }
387     return result
388
389 def check_for_errors(result):
390     result_list = []
391     if not isbatch(result):
392         result_list.append(result)
393     else:
394         result_list = result
395     for entry in result_list:
396         if 'jsonrpc' in entry.keys() and float(entry['jsonrpc']) > 2.0:
397             raise NotImplementedError('JSON-RPC version not yet supported.')
398         if 'error' in entry.keys() and entry['error'] != None:
399             code = entry['error']['code']
400             message = entry['error']['message']
401             raise ProtocolError('ERROR %s: %s' % (code, message))
402     del result_list
403     return result
404
405 def isbatch(result):
406     if type(result) not in (types.ListType, types.TupleType):
407         return False
408     if len(result) < 1:
409         return False
410     if type(result[0]) is not types.DictType:
411         return False
412     if 'jsonrpc' not in result[0].keys():
413         return False
414     try:
415         version = float(result[0]['jsonrpc'])
416     except ValueError:
417         raise ProtocolError('"jsonrpc" key must be a float(able) value.')
418     if version < 2:
419         return False
420     return True
421
422