Whoops, forgot to add parameters to the __notify on Job for the MultiCall, and had...
[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         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         if not response:
183             # notification, no result
184             return None
185         return check_for_errors(response)
186
187     def __getattr__(self, name):
188         # Same as original, just with new _Method and wrapper 
189         # for __notify
190         if name in ('__notify', '__run_request'):
191             wrapped_name = '_%s%s' % (self.__class__.__name__, name)
192             return getattr(self, wrapped_name)
193         return _Method(self.__request, name)
194
195 class _Method(XML_Method):
196     def __call__(self, *args, **kwargs):
197         if len(args) > 0 and len(kwargs) > 0:
198             raise ProtocolError('Cannot use both positional ' +
199                 'and keyword arguments (according to JSON-RPC spec.)')
200         if len(args) > 0:
201             return self.__send(self.__name, args)
202         else:
203             return self.__send(self.__name, kwargs)
204
205 # Batch implementation
206
207 class Job(object):
208     
209     def __init__(self, method, notify=False):
210         self.method = method
211         self.params = []
212         self.notify = notify
213
214     def __call__(self, *args, **kwargs):
215         if len(kwargs) > 0 and len(args) > 0:
216             raise ProtocolError('A Job cannot have both positional ' +
217                                 'and keyword arguments.')
218         if len(kwargs) > 0:
219             self.params = kwargs
220         else:
221             self.params = args
222
223     def request(self, encoding=None, rpcid=None):
224         return dumps(self.params, self.method, version=2.0,
225                      encoding=encoding, rpcid=rpcid, notify=self.notify)
226
227     def __repr__(self):
228         return '%s' % self.request()
229
230 class MultiCall(ServerProxy):
231     
232     def __init__(self, uri, *args, **kwargs):
233         self.__job_list = []
234         ServerProxy.__init__(self, uri, *args, **kwargs)
235
236     def __run_request(self, request_body):
237         run_request = getattr(ServerProxy, '_ServerProxy__run_request')
238         return run_request(self, request_body)
239
240     def __request(self):
241         if len(self.__job_list) < 1:
242             # Should we alert? This /is/ pretty obvious.
243             return
244         request_body = '[ %s ]' % ','.join([job.request() for
245                                           job in self.__job_list])
246         responses = self.__run_request(request_body)
247         del self.__job_list[:]
248         return [ response['result'] for response in responses ]
249
250     def __notify(self, method, params=[]):
251         new_job = Job(method, notify=True)
252         new_job.params = params
253         self.__job_list.append(new_job)
254         
255     def __getattr__(self, name):
256         if name in ('__run', '__notify'):
257             wrapped_name = '_%s%s' % (self.__class__.__name__, name)
258             return getattr(self, wrapped_name)
259         new_job = Job(name)
260         self.__job_list.append(new_job)
261         return new_job
262
263     __call__ = __request
264
265 # These lines conform to xmlrpclib's "compatibility" line. 
266 # Not really sure if we should include these, but oh well.
267 Server = ServerProxy
268
269 class Fault(dict):
270     # JSON-RPC error class
271     def __init__(self, code=-32000, message='Server error'):
272         self.faultCode = code
273         self.faultString = message
274
275     def error(self):
276         return {'code':self.faultCode, 'message':self.faultString}
277
278     def response(self, rpcid=None, version=None):
279         global _version
280         if not version:
281             version = _version
282         return dumps(self, rpcid=None, methodresponse=True,
283                      version=version)
284
285 def random_id(length=8):
286     import string
287     import random
288     random.seed()
289     choices = string.lowercase+string.digits
290     return_id = ''
291     for i in range(length):
292         return_id += random.choice(choices)
293     return return_id
294
295 class Payload(dict):
296     def __init__(self, rpcid=None, version=None):
297         global _version
298         if not version:
299             version = _version
300         self.id = rpcid
301         self.version = float(version)
302     
303     def request(self, method, params=[]):
304         if type(method) not in types.StringTypes:
305             raise ValueError('Method name must be a string.')
306         if not self.id:
307             self.id = random_id()
308         request = {'id':self.id, 'method':method, 'params':params}
309         if self.version >= 2:
310             request['jsonrpc'] = str(self.version)
311         return request
312
313     def notify(self, method, params=[]):
314         request = self.request(method, params)
315         if self.version >= 2:
316             del request['id']
317         else:
318             request['id'] = None
319         return request
320
321     def response(self, result=None):
322         response = {'result':result, 'id':self.id}
323         if self.version >= 2:
324             response['jsonrpc'] = str(self.version)
325         else:
326             response['error'] = None
327         return response
328
329     def error(self, code=-32000, message='Server error.'):
330         error = self.response()
331         if self.version >= 2:
332             del error['result']
333         else:
334             error['result'] = None
335         error['error'] = {'code':code, 'message':message}
336         return error
337
338 def dumps(params=[], methodname=None, methodresponse=None, 
339         encoding=None, rpcid=None, version=None, notify=None):
340     """
341     This differs from the Python implementation in that it implements 
342     the rpcid argument since the 2.0 spec requires it for responses.
343     """
344     global _version
345     if not version:
346         verion = _version
347     valid_params = (types.TupleType, types.ListType, types.DictType)
348     if methodname in types.StringTypes and \
349             type(params) not in valid_params and \
350             not isinstance(params, Fault):
351         """ 
352         If a method, and params are not in a listish or a Fault,
353         error out.
354         """
355         raise TypeError('Params must be a dict, list, tuple or Fault ' +
356                         'instance.')
357     if type(methodname) not in types.StringTypes and methodresponse != True:
358         raise ValueError('Method name must be a string, or methodresponse '+
359                          'must be set to True.')
360     if isinstance(params, Fault) and not methodresponse:
361         raise TypeError('You can only use a Fault for responses.')
362     # Begin parsing object
363     payload = Payload(rpcid=rpcid, version=version)
364     if not encoding:
365         encoding = 'utf-8'
366     if type(params) is Fault:
367         response = payload.error(params.faultCode, params.faultString)
368         return jdumps(response, encoding=encoding)
369     if methodresponse is True:
370         if rpcid is None:
371             raise ValueError('A method response must have an rpcid.')
372         response = payload.response(params)
373         return jdumps(response, encoding=encoding)
374     request = None
375     if notify == True:
376         request = payload.notify(methodname, params)
377     else:
378         request = payload.request(methodname, params)
379     return jdumps(request, encoding=encoding)
380
381 def loads(data):
382     """
383     This differs from the Python implementation, in that it returns
384     the request structure in Dict format instead of the method, params.
385     It will return a list in the case of a batch request / response.
386     """
387     if data == '':
388         # notification
389         return None
390     result = jloads(data)
391     # if the above raises an error, the implementing server code 
392     # should return something like the following:
393     # { 'jsonrpc':'2.0', 'error': fault.error(), id: None }
394     return result
395
396 def check_for_errors(result):
397     result_list = []
398     if not isbatch(result):
399         result_list.append(result)
400     else:
401         result_list = result
402     for entry in result_list:
403         if 'jsonrpc' in entry.keys() and float(entry['jsonrpc']) > 2.0:
404             raise NotImplementedError('JSON-RPC version not yet supported.')
405         if 'error' in entry.keys() and entry['error'] != None:
406             code = entry['error']['code']
407             message = entry['error']['message']
408             raise ProtocolError('ERROR %s: %s' % (code, message))
409     del result_list
410     return result
411
412 def isbatch(result):
413     if type(result) not in (types.ListType, types.TupleType):
414         return False
415     if len(result) < 1:
416         return False
417     if type(result[0]) is not types.DictType:
418         return False
419     if 'jsonrpc' not in result[0].keys():
420         return False
421     try:
422         version = float(result[0]['jsonrpc'])
423     except ValueError:
424         raise ProtocolError('"jsonrpc" key must be a float(able) value.')
425     if version < 2:
426         return False
427     return True
428
429