- use unicode() instead of str() before logging call arguments
[plcapi.git] / PLC / Method.py
1 #
2 # Base class for all PLCAPI functions
3 #
4 # Mark Huang <mlhuang@cs.princeton.edu>
5 # Copyright (C) 2006 The Trustees of Princeton University
6 #
7 # $Id: Method.py,v 1.4 2006/10/13 21:42:25 tmack Exp $
8 #
9
10 import xmlrpclib
11 from types import *
12 import textwrap
13 import os
14 import time
15
16 from PLC.Faults import *
17 from PLC.Parameter import Parameter, Mixed
18 from PLC.Auth import Auth
19 from PLC.Debug import profile, log
20
21 class Method:
22     """
23     Base class for all PLCAPI functions. At a minimum, all PLCAPI
24     functions must define:
25
26     roles = [list of roles]
27     accepts = [Parameter(arg1_type, arg1_doc), Parameter(arg2_type, arg2_doc), ...]
28     returns = Parameter(return_type, return_doc)
29     call(arg1, arg2, ...): method body
30
31     Argument types may be Python types (e.g., int, bool, etc.), typed
32     values (e.g., 1, True, etc.), a Parameter, or lists or
33     dictionaries of possibly mixed types, values, and/or Parameters
34     (e.g., [int, bool, ...]  or {'arg1': int, 'arg2': bool}).
35
36     Once function decorators in Python 2.4 are fully supported,
37     consider wrapping calls with accepts() and returns() functions
38     instead of performing type checking manually.
39     """
40
41     # Defaults. Could implement authentication and type checking with
42     # decorators, but they are not supported in Python 2.3 and it
43     # would be hard to generate documentation without writing a code
44     # parser.
45
46     roles = []
47     accepts = []
48     returns = bool
49     status = "current"
50
51     def call(self, *args):
52         """
53         Method body for all PLCAPI functions. Must override.
54         """
55
56         return True
57
58     def __init__(self, api):
59         self.name = self.__class__.__name__
60         self.api = api
61
62         # Auth may set this to a Person instance (if an anonymous
63         # method, will remain None).
64         self.caller = None
65
66         # API may set this to a (addr, port) tuple if known
67         self.source = None
68
69         
70     def __call__(self, *args):
71         """
72         Main entry point for all PLCAPI functions. Type checks
73         arguments, authenticates, and executes call().
74         """
75
76         try:
77             (min_args, max_args, defaults) = self.args()
78                                 
79             # Check that the right number of arguments were passed in
80             if len(args) < len(min_args) or len(args) > len(max_args):
81                 raise PLCInvalidArgumentCount(len(args), len(min_args), len(max_args))
82
83             for name, value, expected in zip(max_args, args, self.accepts):
84                 self.type_check(name, value, expected)
85         
86             # The first argument to all methods that require
87             # authentication, should be an Auth structure. The rest of the
88             # arguments to the call may also be used in the authentication
89             # check. For example, calls made by the Boot Manager are
90             # verified by comparing a hash of the message parameters to
91             # the value in the authentication structure.        
92
93             if len(self.accepts):
94                 auth = None
95                 if isinstance(self.accepts[0], Auth):
96                     auth = self.accepts[0]
97                 elif isinstance(self.accepts[0], Mixed):
98                     for auth in self.accepts[0]:
99                         if isinstance(auth, Auth):
100                             break
101                 if isinstance(auth, Auth):
102                     auth.check(self, *args)
103     
104             if self.api.config.PLC_API_DEBUG:
105                 self.log(0, *args)
106                 
107             return self.call(*args)
108
109         except PLCFault, fault:
110             # Prepend method name to expected faults
111             fault.faultString = self.name + ": " + fault.faultString
112             self.log(fault.faultCode, *args)
113             raise fault
114
115
116     def log(self, fault_code, *args):
117         """
118         Log the transaction 
119         """     
120         # Gather necessary logging variables
121         event_type = 'Unknown'
122         object_type = 'Unknown'
123         person_id = 0
124         object_ids = []
125         call_name = self.name
126         call_args = ", ".join([unicode(arg) for arg in list(args)[1:]]).replace('\'', '\\\'')
127         call = "%s(%s)" % (call_name, call_args)
128                 
129         if hasattr(self, 'event_type'):
130                 event_type = self.event_type
131         if hasattr(self, 'object_type'):
132                 object_type = self.object_type
133         if self.caller:
134                 person_id = self.caller['person_id']
135         if hasattr(self, 'object_ids'):
136                 object_ids = self.object_ids 
137
138         # make sure this is an api call
139         if call_name in ['system.listMethods', 'system.methodHelp', 'system.multicall', 'system.methodSignature']:
140                 return False
141
142         sql_event = "INSERT INTO events " \
143               " (person_id, event_type, object_type, fault_code, call) VALUES" \
144               " (%(person_id)d, '%(event_type)s', '%(object_type)s', %(fault_code)d, '%(call)s')" %  \
145               (locals())
146
147         # XX get real event id
148         event_id = 1    
149         # log objects affected
150         # XX this probably wont work (we only know objects affected after call is made. Find another way
151         if object_ids:
152                 affected_id_list = zip([event_id for object in object_ids], object_ids)
153                 affected_ids = ", ".join(map(str,affected_id_list))
154                 sql_objects = "INSERT INTO event_objects (event_id, object_id) VALUES" \
155                         " %s " % affected_ids
156  
157         self.api.db.do(sql_event)
158         self.api.db.commit()            
159         
160
161     def help(self, indent = "  "):
162         """
163         Text documentation for the method.
164         """
165
166         (min_args, max_args, defaults) = self.args()
167
168         text = "%s(%s) -> %s\n\n" % (self.name, ", ".join(max_args), xmlrpc_type(self.returns))
169
170         text += "Description:\n\n"
171         lines = [indent + line.strip() for line in self.__doc__.strip().split("\n")]
172         text += "\n".join(lines) + "\n\n"
173
174         text += "Allowed Roles:\n\n"
175         if not self.roles:
176             roles = ["any"]
177         else:
178             roles = self.roles
179         text += indent + ", ".join(roles) + "\n\n"
180
181         def param_text(name, param, indent, step):
182             """
183             Format a method parameter.
184             """
185
186             text = indent
187
188             # Print parameter name
189             if name:
190                 param_offset = 32
191                 text += name.ljust(param_offset - len(indent))
192             else:
193                 param_offset = len(indent)
194
195             # Print parameter type
196             param_type = python_type(param)
197             text += xmlrpc_type(param_type) + "\n"
198
199             # Print parameter documentation right below type
200             if isinstance(param, Parameter):
201                 wrapper = textwrap.TextWrapper(width = 70,
202                                                initial_indent = " " * param_offset,
203                                                subsequent_indent = " " * param_offset)
204                 text += "\n".join(wrapper.wrap(param.doc)) + "\n"
205                 param = param.type
206
207             text += "\n"
208
209             # Indent struct fields and mixed types
210             if isinstance(param, dict):
211                 for name, subparam in param.iteritems():
212                     text += param_text(name, subparam, indent + step, step)
213             elif isinstance(param, Mixed):
214                 for subparam in param:
215                     text += param_text(name, subparam, indent + step, step)
216             elif isinstance(param, (list, tuple)):
217                 for subparam in param:
218                     text += param_text("", subparam, indent + step, step)
219
220             return text
221
222         text += "Parameters:\n\n"
223         for name, param in zip(max_args, self.accepts):
224             text += param_text(name, param, indent, indent)
225
226         text += "Returns:\n\n"
227         text += param_text("", self.returns, indent, indent)
228
229         return text
230
231     def args(self):
232         """
233         Returns a tuple:
234
235         ((arg1_name, arg2_name, ...),
236          (arg1_name, arg2_name, ..., optional1_name, optional2_name, ...),
237          (None, None, ..., optional1_default, optional2_default, ...))
238
239         That represents the minimum and maximum sets of arguments that
240         this function accepts and the defaults for the optional arguments.
241         """
242
243         # Inspect call. Remove self from the argument list.
244         max_args = self.call.func_code.co_varnames[1:self.call.func_code.co_argcount]
245         defaults = self.call.func_defaults
246         if defaults is None:
247             defaults = ()
248
249         min_args = max_args[0:len(max_args) - len(defaults)]
250         defaults = tuple([None for arg in min_args]) + defaults
251         
252         return (min_args, max_args, defaults)
253
254     def type_check(self, name, value, expected, min = None, max = None):
255         """
256         Checks the type of the named value against the expected type,
257         which may be a Python type, a typed value, a Parameter, a
258         Mixed type, or a list or dictionary of possibly mixed types,
259         values, Parameters, or Mixed types.
260         
261         Extraneous members of lists must be of the same type as the
262         last specified type. For example, if the expected argument
263         type is [int, bool], then [1, False] and [14, True, False,
264         True] are valid, but [1], [False, 1] and [14, True, 1] are
265         not.
266
267         Extraneous members of dictionaries are ignored.
268         """
269
270         # If any of a number of types is acceptable
271         if isinstance(expected, Mixed):
272             for item in expected:
273                 try:
274                     self.type_check(name, value, item)
275                     expected = item
276                     break
277                 except PLCInvalidArgument, fault:
278                     pass
279             if expected != item:
280                 xmlrpc_types = [xmlrpc_type(item) for item in expected]
281                 raise PLCInvalidArgument("expected %s, got %s" % \
282                                          (" or ".join(xmlrpc_types),
283                                           xmlrpc_type(type(value))),
284                                          name)
285
286         # Get actual expected type from within the Parameter structure
287         elif isinstance(expected, Parameter):
288             min = expected.min
289             max = expected.max
290             expected = expected.type
291
292         expected_type = python_type(expected)
293
294         # Strings are a special case. Accept either unicode or str
295         # types if a string is expected.
296         if expected_type in StringTypes and isinstance(value, StringTypes):
297             pass
298
299         # Integers and long integers are also special types. Accept
300         # either int or long types if an int or long is expected.
301         elif expected_type in (IntType, LongType) and isinstance(value, (IntType, LongType)):
302             pass
303
304         elif not isinstance(value, expected_type):
305             raise PLCInvalidArgument("expected %s, got %s" % \
306                                      (xmlrpc_type(expected_type),
307                                       xmlrpc_type(type(value))),
308                                      name)
309
310         # If a minimum or maximum (length, value) has been specified
311         if expected_type in StringTypes:
312             if min is not None and \
313                len(value.encode(self.api.encoding)) < min:
314                 raise PLCInvalidArgument, "%s must be at least %d bytes long" % (name, min)
315             if max is not None and \
316                len(value.encode(self.api.encoding)) > max:
317                 raise PLCInvalidArgument, "%s must be at most %d bytes long" % (name, max)
318         else:
319             if min is not None and value < min:
320                 raise PLCInvalidArgument, "%s must be > %s" % (name, str(min))
321             if max is not None and value > max:
322                 raise PLCInvalidArgument, "%s must be < %s" % (name, str(max))
323
324         # If a list with particular types of items is expected
325         if isinstance(expected, (list, tuple)):
326             for i in range(len(value)):
327                 if i >= len(expected):
328                     i = len(expected) - 1
329                 self.type_check(name + "[]", value[i], expected[i])
330
331         # If a struct with particular (or required) types of items is
332         # expected.
333         elif isinstance(expected, dict):
334             for key in value.keys():
335                 if key in expected:
336                     self.type_check(name + "['%s']" % key, value[key], expected[key])
337             for key, subparam in expected.iteritems():
338                 if isinstance(subparam, Parameter) and \
339                    not subparam.optional and key not in value.keys():
340                     raise PLCInvalidArgument("'%s' not specified" % key, name)
341
342 def python_type(arg):
343     """
344     Returns the Python type of the specified argument, which may be a
345     Python type, a typed value, or a Parameter.
346     """
347
348     if isinstance(arg, Parameter):
349         arg = arg.type
350
351     if isinstance(arg, type):
352         return arg
353     else:
354         return type(arg)
355
356 def xmlrpc_type(arg):
357     """
358     Returns the XML-RPC type of the specified argument, which may be a
359     Python type, a typed value, or a Parameter.
360     """
361
362     arg_type = python_type(arg)
363
364     if arg_type == NoneType:
365         return "nil"
366     elif arg_type == IntType or arg_type == LongType:
367         return "int"
368     elif arg_type == bool:
369         return "boolean"
370     elif arg_type == FloatType:
371         return "double"
372     elif arg_type in StringTypes:
373         return "string"
374     elif arg_type == ListType or arg_type == TupleType:
375         return "array"
376     elif arg_type == DictType:
377         return "struct"
378     elif arg_type == Mixed:
379         # Not really an XML-RPC type but return "mixed" for
380         # documentation purposes.
381         return "mixed"
382     else:
383         raise PLCAPIError, "XML-RPC cannot marshal %s objects" % arg_type