Tweaked MultiCall so it takes a 'ServerProxy' instance instead of a string (like...
[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         if response_body == '':
110             # Notification
111             return None
112         return_obj = loads(response_body)
113         return return_obj
114
115 class SafeTransport(XMLSafeTransport):
116     """ Just extends for HTTPS calls """
117     user_agent = Transport.user_agent
118     send_content = Transport.send_content
119     _parse_response = Transport._parse_response
120
121 class ServerProxy(XMLServerProxy):
122     """
123     Unfortunately, much more of this class has to be copied since
124     so much of it does the serialization.
125     """
126
127     def __init__(self, uri, transport=None, encoding=None, 
128                  verbose=0, version=None):
129         import urllib
130         global _version
131         if not version:
132             version = _version
133         self.__version = version
134         schema, uri = urllib.splittype(uri)
135         if schema not in ('http', 'https'):
136             raise IOError('Unsupported JSON-RPC protocol.')
137         self.__host, self.__handler = urllib.splithost(uri)
138         if not self.__handler:
139             # Not sure if this is in the JSON spec?
140             self.__handler = '/RPC2'
141         if transport is None:
142             if schema == 'https':
143                 transport = SafeTransport()
144             else:
145                 transport = Transport()
146         self.__transport = transport
147         self.__encoding = encoding
148         self.__verbose = verbose
149
150     def __request(self, methodname, params, rpcid=None):
151         request = dumps(params, methodname, encoding=self.__encoding,
152                         rpcid=rpcid, version=self.__version)
153         response = self.__run_request(request)
154         return response['result']
155     
156     def __notify(self, methodname, params, rpcid=None):
157         request = dumps(params, methodname, encoding=self.__encoding,
158                         rpcid=rpcid, version=self.__version, notify=True)
159         print request
160         response = self.__run_request(request, notify=True)
161         return
162
163     def __run_request(self, request, notify=None):
164         global _last_request
165         global _last_response
166         _last_request = request
167         
168         print request
169
170         response = self.__transport.request(
171             self.__host,
172             self.__handler,
173             request,
174             verbose=self.__verbose
175         )
176         
177         # Here, the XMLRPC library translates a single list
178         # response to the single value -- should we do the
179         # same, and require a tuple / list to be passed to
180         # the response object, or expect the Server to be 
181         # outputting the response appropriately?
182         
183         _last_response = response
184         if not response:
185             # notification, no result
186             return None
187         return check_for_errors(response)
188
189     def __getattr__(self, name):
190         # Same as original, just with new _Method and wrapper 
191         # for __notify
192         if name in ('__notify', '__run_request'):
193             wrapped_name = '_%s%s' % (self.__class__.__name__, name)
194             return getattr(self, wrapped_name)
195         return _Method(self.__request, name)
196
197 class _Method(XML_Method):
198     def __call__(self, *args, **kwargs):
199         if len(args) > 0 and len(kwargs) > 0:
200             raise ProtocolError('Cannot use both positional ' +
201                 'and keyword arguments (according to JSON-RPC spec.)')
202         if len(args) > 0:
203             return self.__send(self.__name, args)
204         else:
205             return self.__send(self.__name, kwargs)
206
207 # Batch implementation
208
209 class MultiCallMethod(object):
210     
211     def __init__(self, method, notify=False):
212         self.method = method
213         self.params = []
214         self.notify = notify
215
216     def __call__(self, *args, **kwargs):
217         if len(kwargs) > 0 and len(args) > 0:
218             raise ProtocolError('A Job cannot have both positional ' +
219                                 'and keyword arguments.')
220         if len(kwargs) > 0:
221             self.params = kwargs
222         else:
223             self.params = args
224
225     def request(self, encoding=None, rpcid=None):
226         return dumps(self.params, self.method, version=2.0,
227                      encoding=encoding, rpcid=rpcid, notify=self.notify)
228
229     def __repr__(self):
230         return '%s' % self.request()
231
232 class MultiCall(object):
233     
234     def __init__(self, server):
235         self.__server = server
236         self.__job_list = []
237
238     def __run_request(self, request_body):
239         run_request = getattr(self.__server, '_ServerProxy__run_request')
240         return run_request(request_body)
241
242     def __request(self):
243         if len(self.__job_list) < 1:
244             # Should we alert? This /is/ pretty obvious.
245             return
246         request_body = '[ %s ]' % ','.join([job.request() for
247                                           job in self.__job_list])
248         responses = self.__run_request(request_body)
249         del self.__job_list[:]
250         return [ response['result'] for response in responses ]
251
252     def __notify(self, method, params=[]):
253         new_job = MultiCallMethod(method, notify=True)
254         new_job.params = params
255         self.__job_list.append(new_job)
256         
257     def __getattr__(self, name):
258         if name in ('__run', '__notify'):
259             wrapped_name = '_%s%s' % (self.__class__.__name__, name)
260             return getattr(self, wrapped_name)
261         new_job = MultiCallMethod(name)
262         self.__job_list.append(new_job)
263         return new_job
264
265     __call__ = __request
266
267 # These lines conform to xmlrpclib's "compatibility" line. 
268 # Not really sure if we should include these, but oh well.
269 Server = ServerProxy
270
271 class Fault(object):
272     # JSON-RPC error class
273     def __init__(self, code=-32000, message='Server error'):
274         self.faultCode = code
275         self.faultString = message
276
277     def error(self):
278         return {'code':self.faultCode, 'message':self.faultString}
279
280     def response(self, rpcid=None, version=None):
281         global _version
282         if not version:
283             version = _version
284         return dumps(self, rpcid=rpcid, version=version)
285
286     def __repr__(self):
287         return '<Fault %s: %s>' % (self.faultCode, self.faultString)
288
289 def random_id(length=8):
290     import string
291     import random
292     random.seed()
293     choices = string.lowercase+string.digits
294     return_id = ''
295     for i in range(length):
296         return_id += random.choice(choices)
297     return return_id
298
299 class Payload(dict):
300     def __init__(self, rpcid=None, version=None):
301         global _version
302         if not version:
303             version = _version
304         self.id = rpcid
305         self.version = float(version)
306     
307     def request(self, method, params=[]):
308         if type(method) not in types.StringTypes:
309             raise ValueError('Method name must be a string.')
310         if not self.id:
311             self.id = random_id()
312         request = {'id':self.id, 'method':method, 'params':params}
313         if self.version >= 2:
314             request['jsonrpc'] = str(self.version)
315         return request
316
317     def notify(self, method, params=[]):
318         request = self.request(method, params)
319         if self.version >= 2:
320             del request['id']
321         else:
322             request['id'] = None
323         return request
324
325     def response(self, result=None):
326         response = {'result':result, 'id':self.id}
327         if self.version >= 2:
328             response['jsonrpc'] = str(self.version)
329         else:
330             response['error'] = None
331         return response
332
333     def error(self, code=-32000, message='Server error.'):
334         error = self.response()
335         if self.version >= 2:
336             del error['result']
337         else:
338             error['result'] = None
339         error['error'] = {'code':code, 'message':message}
340         return error
341
342 def dumps(params=[], methodname=None, methodresponse=None, 
343         encoding=None, rpcid=None, version=None, notify=None):
344     """
345     This differs from the Python implementation in that it implements 
346     the rpcid argument since the 2.0 spec requires it for responses.
347     """
348     global _version
349     if not version:
350         version = _version
351     valid_params = (types.TupleType, types.ListType, types.DictType)
352     if methodname in types.StringTypes and \
353             type(params) not in valid_params and \
354             not isinstance(params, Fault):
355         """ 
356         If a method, and params are not in a listish or a Fault,
357         error out.
358         """
359         raise TypeError('Params must be a dict, list, tuple or Fault ' +
360                         'instance.')
361     # Begin parsing object
362     payload = Payload(rpcid=rpcid, version=version)
363     if not encoding:
364         encoding = 'utf-8'
365     if type(params) is Fault:
366         response = payload.error(params.faultCode, params.faultString)
367         return jdumps(response, encoding=encoding)
368     if type(methodname) not in types.StringTypes and methodresponse != True:
369         raise ValueError('Method name must be a string, or methodresponse '+
370                          'must be set to True.')
371     if methodresponse is True:
372         if rpcid is None:
373             raise ValueError('A method response must have an rpcid.')
374         response = payload.response(params)
375         return jdumps(response, encoding=encoding)
376     request = None
377     if notify == True:
378         request = payload.notify(methodname, params)
379     else:
380         request = payload.request(methodname, params)
381     return jdumps(request, encoding=encoding)
382
383 def loads(data):
384     """
385     This differs from the Python implementation, in that it returns
386     the request structure in Dict format instead of the method, params.
387     It will return a list in the case of a batch request / response.
388     """
389     if data == '':
390         # notification
391         return None
392     result = jloads(data)
393     # if the above raises an error, the implementing server code 
394     # should return something like the following:
395     # { 'jsonrpc':'2.0', 'error': fault.error(), id: None }
396     return result
397
398 def check_for_errors(result):
399     result_list = []
400     if not isbatch(result):
401         result_list.append(result)
402     else:
403         result_list = result
404     for entry in result_list:
405         if 'jsonrpc' in entry.keys() and float(entry['jsonrpc']) > 2.0:
406             raise NotImplementedError('JSON-RPC version not yet supported.')
407         if 'error' in entry.keys() and entry['error'] != None:
408             code = entry['error']['code']
409             message = entry['error']['message']
410             raise ProtocolError('ERROR %s: %s' % (code, message))
411     del result_list
412     return result
413
414 def isbatch(result):
415     if type(result) not in (types.ListType, types.TupleType):
416         return False
417     if len(result) < 1:
418         return False
419     if type(result[0]) is not types.DictType:
420         return False
421     if 'jsonrpc' not in result[0].keys():
422         return False
423     try:
424         version = float(result[0]['jsonrpc'])
425     except ValueError:
426         raise ProtocolError('"jsonrpc" key must be a float(able) value.')
427     if version < 2:
428         return False
429     return True
430
431