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