more ironing in the corner of that 'types' modules
[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 #import xmlrpc.client
8 import textwrap
9 import time
10 import pprint
11
12 from PLC.Faults import *
13 from PLC.Parameter import Parameter, Mixed, python_type, xmlrpc_type
14 from PLC.Auth import Auth
15 #from PLC.Debug import profile
16 from PLC.Events import Event, Events
17 from PLC.Nodes import Node, Nodes
18 from PLC.Persons import Person, Persons
19
20 # we inherit object because we use new-style classes for legacy methods
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, caller=None):
59         self.name = self.__class__.__name__
60         self.api = api
61
62         if caller:
63             # let a method call another one by propagating its caller
64             self.caller = caller
65         else:
66             # Auth may set this to a Person instance (if an anonymous
67             # method, will remain None).
68             self.caller = None
69
70
71         # API may set this to a (addr, port) tuple if known
72         self.source = None
73
74     def __call__(self, *args, **kwds):
75         """
76         Main entry point for all PLCAPI functions. Type checks
77         arguments, authenticates, and executes call().
78         """
79
80         try:
81             start = time.time()
82
83             # legacy code cannot be type-checked, due to the way Method.args() works
84             # as of 5.0-rc16 we don't use skip_type_check anymore
85             if not hasattr(self, "skip_type_check"):
86                 (min_args, max_args, defaults) = self.args()
87
88                 # Check that the right number of arguments were passed in
89                 if len(args) < len(min_args) or len(args) > len(max_args):
90                     raise PLCInvalidArgumentCount(len(args), len(min_args), len(max_args))
91
92                 for name, value, expected in zip(max_args, args, self.accepts):
93                     self.type_check(name, value, expected, args)
94
95             result = self.call(*args, **kwds)
96             runtime = time.time() - start
97
98             if self.api.config.PLC_API_DEBUG or hasattr(self, 'message'):
99                 self.log(None, runtime, *args)
100
101             return result
102
103         except PLCFault as fault:
104
105             caller = ""
106             if isinstance(self.caller, Person):
107                 caller = 'person_id %s'  % self.caller['person_id']
108             elif isinstance(self.caller, Node):
109                 caller = 'node_id %s'  % self.caller['node_id']
110
111             # Prepend caller and method name to expected faults
112             fault.faultString = caller + ": " +  self.name + ": " + fault.faultString
113             runtime = time.time() - start
114
115             if self.api.config.PLC_API_DEBUG:
116                 self.log(fault, runtime, *args)
117
118             raise fault
119
120     def log(self, fault, runtime, *args):
121         """
122         Log the transaction
123         """
124
125         # Do not log system or Get calls
126         #if self.name.startswith('system') or self.name.startswith('Get'):
127         #    return False
128         # Do not log ReportRunlevel
129         if self.name.startswith('system'):
130             return False
131         if self.name.startswith('ReportRunlevel'):
132             return False
133
134         # Create a new event
135         event = Event(self.api)
136         event['fault_code'] = 0
137         if fault:
138             event['fault_code'] = fault.faultCode
139         event['runtime'] = runtime
140
141         # Redact passwords and sessions
142         newargs = args
143         if args:
144             newargs = []
145             for arg in args:
146                 if not isinstance(arg, dict):
147                     newargs.append(arg)
148                     continue
149                 # what type of auth this is
150                 if 'AuthMethod' in arg:
151                     auth_methods = ['session', 'password', 'capability',
152                                     'gpg', 'hmac', 'anonymous']
153                     auth_method = arg['AuthMethod']
154                     if auth_method in auth_methods:
155                         event['auth_type'] = auth_method
156                 for password in 'AuthString', 'session', 'password':
157                     if password in arg:
158                         arg = arg.copy()
159                         arg[password] = "Removed by API"
160                 newargs.append(arg)
161
162         # Log call representation
163         # XXX Truncate to avoid DoS
164         event['call'] = self.name + pprint.saferepr(newargs)
165         event['call_name'] = self.name
166
167         # Both users and nodes can call some methods
168         if isinstance(self.caller, Person):
169             event['person_id'] = self.caller['person_id']
170         elif isinstance(self.caller, Node):
171             event['node_id'] = self.caller['node_id']
172
173         event.sync(commit=False)
174
175         if hasattr(self, 'event_objects') and isinstance(self.event_objects, dict):
176             for key in self.event_objects.keys():
177                 for object_id in self.event_objects[key]:
178                     event.add_object(key, object_id, commit=False)
179
180
181         # Set the message for this event
182         if fault:
183             event['message'] = fault.faultString
184         elif hasattr(self, 'message'):
185             event['message'] = self.message
186
187         # Commit
188         event.sync()
189
190     def help(self, indent="  "):
191         """
192         Text documentation for the method.
193         """
194
195         (min_args, max_args, defaults) = self.args()
196
197         text = "%s(%s) -> %s\n\n" % (self.name, ", ".join(max_args), xmlrpc_type(self.returns))
198
199         text += "Description:\n\n"
200         lines = [indent + line.strip() for line in self.__doc__.strip().split("\n")]
201         text += "\n".join(lines) + "\n\n"
202
203         text += "Allowed Roles:\n\n"
204         if not self.roles:
205             roles = ["any"]
206         else:
207             roles = self.roles
208         text += indent + ", ".join(roles) + "\n\n"
209
210         def param_text(name, param, indent, step):
211             """
212             Format a method parameter.
213             """
214
215             text = indent
216
217             # Print parameter name
218             if name:
219                 param_offset = 32
220                 text += name.ljust(param_offset - len(indent))
221             else:
222                 param_offset = len(indent)
223
224             # Print parameter type
225             param_type = python_type(param)
226             text += xmlrpc_type(param_type) + "\n"
227
228             # Print parameter documentation right below type
229             if isinstance(param, Parameter):
230                 wrapper = textwrap.TextWrapper(
231                     width=70,
232                     initial_indent=" " * param_offset,
233                     subsequent_indent = " " * param_offset)
234                 text += "\n".join(wrapper.wrap(param.doc)) + "\n"
235                 param = param.type
236
237             text += "\n"
238
239             # Indent struct fields and mixed types
240             if isinstance(param, dict):
241                 for name, subparam in param.items():
242                     text += param_text(name, subparam, indent + step, step)
243             elif isinstance(param, Mixed):
244                 for subparam in param:
245                     text += param_text(name, subparam, indent + step, step)
246             elif isinstance(param, (list, tuple, set)):
247                 for subparam in param:
248                     text += param_text("", subparam, indent + step, step)
249
250             return text
251
252         text += "Parameters:\n\n"
253         for name, param in zip(max_args, self.accepts):
254             text += param_text(name, param, indent, indent)
255
256         text += "Returns:\n\n"
257         text += param_text("", self.returns, indent, indent)
258
259         return text
260
261     def args(self):
262         """
263         Returns a tuple:
264
265         ((arg1_name, arg2_name, ...),
266          (arg1_name, arg2_name, ..., optional1_name, optional2_name, ...),
267          (None, None, ..., optional1_default, optional2_default, ...))
268
269         That represents the minimum and maximum sets of arguments that
270         this function accepts and the defaults for the optional arguments.
271         """
272
273         # Inspect call. Remove self from the argument list.
274         max_args = self.call.__code__.co_varnames[1:self.call.__code__.co_argcount]
275         defaults = self.call.__defaults__
276         if defaults is None:
277             defaults = ()
278
279         min_args = max_args[0:len(max_args) - len(defaults)]
280         defaults = tuple([None for arg in min_args]) + defaults
281
282         return (min_args, max_args, defaults)
283
284     def type_check(self, name, value, expected, args):
285         """
286         Checks the type of the named value against the expected type,
287         which may be a Python type, a typed value, a Parameter, a
288         Mixed type, or a list or dictionary of possibly mixed types,
289         values, Parameters, or Mixed types.
290
291         Extraneous members of lists must be of the same type as the
292         last specified type. For example, if the expected argument
293         type is [int, bool], then [1, False] and [14, True, False,
294         True] are valid, but [1], [False, 1] and [14, True, 1] are
295         not.
296
297         Extraneous members of dictionaries are ignored.
298         """
299
300         # If any of a number of types is acceptable
301         if isinstance(expected, Mixed):
302             for item in expected:
303                 try:
304                     self.type_check(name, value, item, args)
305                     return
306                 except PLCInvalidArgument as fault:
307                     pass
308             raise fault
309
310         # If an authentication structure is expected, save it and
311         # authenticate after basic type checking is done.
312         if isinstance(expected, Auth):
313             auth = expected
314         else:
315             auth = None
316
317         # Get actual expected type from within the Parameter structure
318         if isinstance(expected, Parameter):
319             min = expected.min
320             max = expected.max
321             nullok = expected.nullok
322             expected = expected.type
323         else:
324             min = None
325             max = None
326             nullok = False
327
328         expected_type = python_type(expected)
329
330         # If value can be NULL
331         if value is None and nullok:
332             return
333
334         # Strings are a special case. Accept either unicode or str
335         # types if a string is expected.
336         if expected_type is str and isinstance(value, str):
337             pass
338
339         # Integers and long integers are also special types. Accept
340         # either int or long types if an int or long is expected.
341     elif expected_type is int and isinstance(value, int):
342             pass
343
344         elif not isinstance(value, expected_type):
345             raise PLCInvalidArgument("expected %s, got %s" % \
346                                      (xmlrpc_type(expected_type),
347                                       xmlrpc_type(type(value))),
348                                      name)
349
350         # If a minimum or maximum (length, value) has been specified
351         if expected_type is str:
352             if min is not None and \
353                len(value.encode(self.api.encoding)) < min:
354                 raise PLCInvalidArgument("%s must be at least %d bytes long" % (name, min))
355             if max is not None and \
356                len(value.encode(self.api.encoding)) > max:
357                 raise PLCInvalidArgument("%s must be at most %d bytes long" % (name, max))
358         elif expected_type in (list, tuple, set):
359             if min is not None and len(value) < min:
360                 raise PLCInvalidArgument("%s must contain at least %d items" % (name, min))
361             if max is not None and len(value) > max:
362                 raise PLCInvalidArgument("%s must contain at most %d items" % (name, max))
363         else:
364             if min is not None and value < min:
365                 raise PLCInvalidArgument("%s must be > %s" % (name, str(min)))
366             if max is not None and value > max:
367                 raise PLCInvalidArgument("%s must be < %s" % (name, str(max)))
368
369         # If a list with particular types of items is expected
370         if isinstance(expected, (list, tuple, set)):
371             for i in range(len(value)):
372                 if i >= len(expected):
373                     j = len(expected) - 1
374                 else:
375                     j = i
376                 self.type_check(name + "[]", value[i], expected[j], args)
377
378         # If a struct with particular (or required) types of items is
379         # expected.
380         elif isinstance(expected, dict):
381             for key in list(value.keys()):
382                 if key in expected:
383                     self.type_check(name + "['%s']" % key, value[key], expected[key], args)
384             for key, subparam in expected.items():
385                 if isinstance(subparam, Parameter) and \
386                    subparam.optional is not None and \
387                    not subparam.optional and key not in list(value.keys()):
388                     raise PLCInvalidArgument("'%s' not specified" % key, name)
389
390         if auth is not None:
391             auth.check(self, *args)