/
decorators.py
617 lines (511 loc) · 20.8 KB
/
decorators.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
# -*- coding: utf-8 -*-
import functools
import inspect
import json
import os
import types
from io import open
import requests
import six
import wrapt
try:
from lark import ParseError
except ImportError:
from lark.common import ParseError
from brewtils.choices import parse
from brewtils.errors import PluginParamError
from brewtils.models import Command, Parameter, Choices
__all__ = [
"system",
"parameter",
"parameters",
"command",
"command_registrar",
"plugin_param",
"register",
]
# The wrapt module has a cool feature where you can disable wrapping a decorated function,
# instead just using the original function. This is pretty much exactly what we want - we
# aren't using decorators for their 'real' purpose of wrapping a function, we just want to add
# some metadata to the function object. So we'll disable the wrapping normally, but we need to
# test that enabling the wrapping would work.
_wrap_functions = False
def system(cls):
"""Class decorator that marks a class as a beer-garden System
Creates a ``_commands`` property on the class that holds all registered commands.
:param cls: The class to decorated
:return: The decorated class
"""
import brewtils.plugin
commands = []
for method_name in dir(cls):
method = getattr(cls, method_name)
method_command = getattr(method, "_command", None)
if method_command:
commands.append(method_command)
cls._commands = commands
cls._current_request = property(
lambda self: brewtils.plugin.request_context.current_request
)
return cls
def command(
_wrapped=None,
command_type="ACTION",
output_type="STRING",
schema=None,
form=None,
template=None,
icon_name=None,
description=None,
):
"""Decorator that marks a function as a beer-garden command
For example:
.. code-block:: python
@command(output_type='JSON')
def echo_json(self, message):
return message
:param _wrapped: The function to decorate. This is handled as a positional argument and
shouldn't be explicitly set.
:param command_type: The command type. Valid options are Command.COMMAND_TYPES.
:param output_type: The output type. Valid options are Command.OUTPUT_TYPES.
:param schema: A custom schema definition.
:param form: A custom form definition.
:param template: A custom template definition.
:param icon_name: The icon name. Should be either a FontAwesome or a Glyphicon name.
:param description: The command description. Will override the function's docstring.
:return: The decorated function.
"""
if _wrapped is None:
return functools.partial(
command,
command_type=command_type,
output_type=output_type,
schema=schema,
form=form,
template=template,
icon_name=icon_name,
description=description,
)
generated_command = _generate_command_from_function(_wrapped)
generated_command.command_type = command_type
generated_command.output_type = output_type
generated_command.icon_name = icon_name
if description:
generated_command.description = description
resolved_mod = _resolve_display_modifiers(
_wrapped, generated_command.name, schema=schema, form=form, template=template
)
generated_command.schema = resolved_mod["schema"]
generated_command.form = resolved_mod["form"]
generated_command.template = resolved_mod["template"]
func_command = getattr(_wrapped, "_command", None)
if func_command:
_update_func_command(func_command, generated_command)
else:
_wrapped._command = generated_command
@wrapt.decorator(enabled=_wrap_functions)
def wrapper(_double_wrapped, _, _args, _kwargs):
return _double_wrapped(*_args, **_kwargs)
return wrapper(_wrapped)
def parameter(
_wrapped=None,
key=None,
type=None,
multi=None,
display_name=None,
optional=None,
default=None,
description=None,
choices=None,
nullable=None,
maximum=None,
minimum=None,
regex=None,
is_kwarg=None,
model=None,
form_input_type=None,
):
"""Decorator that enables Parameter specifications for a beer-garden Command
This decorator is intended to be used when more specification is desired for a Parameter.
For example::
@parameter(key="message", description="Message to echo", optional=True, type="String",
default="Hello, World!")
def echo(self, message):
return message
:param _wrapped: The function to decorate. This is handled as a positional argument and
shouldn't be explicitly set.
:param key: String specifying the parameter identifier. Must match an argument name of the
decorated function.
:param type: String indicating the type to use for this parameter.
:param multi: Boolean indicating if this parameter is a multi. See documentation for
discussion of what this means.
:param display_name: String that will be displayed as a label in the user interface.
:param optional: Boolean indicating if this parameter must be specified.
:param default: The value this parameter will be assigned if not overridden when creating a
request.
:param description: An additional string that will be displayed in the user interface.
:param choices: List or dictionary specifying allowed values. See documentation for more
information.
:param nullable: Boolean indicating if this parameter is allowed to be null.
:param maximum: Integer indicating the maximum value of the parameter.
:param minimum: Integer indicating the minimum value of the parameter.
:param regex: String describing a regular expression constraint on the parameter.
:param is_kwarg: Boolean indicating if this parameter is meant to be part of the decorated
function's kwargs.
:param model: Class to be used as a model for this parameter. Must be a Python type object,
not an instance.
:param form_input_type: Only used for string fields. Changes the form input field
(e.g. textarea)
:return: The decorated function.
"""
if _wrapped is None:
return functools.partial(
parameter,
key=key,
type=type,
multi=multi,
display_name=display_name,
optional=optional,
default=default,
description=description,
choices=choices,
nullable=nullable,
maximum=maximum,
minimum=minimum,
regex=regex,
is_kwarg=is_kwarg,
model=model,
form_input_type=form_input_type,
)
# Create a command object if one isn't already associated
cmd = getattr(_wrapped, "_command", None)
if not cmd:
cmd = _generate_command_from_function(_wrapped)
_wrapped._command = cmd
# Every parameter needs a key, so stop that right here
if key is None:
raise PluginParamError(
"Found a parameter definition without a key for " "command '%s'" % cmd.name
)
# If the command doesn't already have a parameter with this key then the
# method doesn't have an explicit keyword argument with <key> as the name.
# That's only OK if this parameter is meant to be part of the **kwargs.
param = cmd.get_parameter_by_key(key)
if param is None:
if is_kwarg:
param = Parameter(key=key, optional=False)
cmd.parameters.append(param)
else:
raise PluginParamError(
"Parameter '%s' was not an explicit keyword argument for "
"command '%s' and was not marked as part of kwargs (should "
"is_kwarg be True?)" % (key, cmd.name)
)
# Update parameter definition with the plugin_param arguments
param.type = _format_type(param.type if type is None else type)
param.multi = param.multi if multi is None else multi
param.display_name = param.display_name if display_name is None else display_name
param.optional = param.optional if optional is None else optional
param.default = param.default if default is None else default
param.description = param.description if description is None else description
param.choices = param.choices if choices is None else choices
param.nullable = param.nullable if nullable is None else nullable
param.maximum = param.maximum if maximum is None else maximum
param.minimum = param.minimum if minimum is None else minimum
param.regex = param.regex if regex is None else regex
param.form_input_type = (
param.form_input_type if form_input_type is None else form_input_type
)
param.choices = _format_choices(param.choices)
# Model is another special case - it requires its own handling
if model is not None:
param.type = "Dictionary"
param.parameters = _generate_nested_params(model)
# If the model is not nullable and does not have a default we will try
# to generate a one using the defaults defined on the model parameters
if not param.nullable and not param.default:
param.default = {}
for nested_param in param.parameters:
if nested_param.default:
param.default[nested_param.key] = nested_param.default
@wrapt.decorator(enabled=_wrap_functions)
def wrapper(_double_wrapped, _, _args, _kwargs):
return _double_wrapped(*_args, **_kwargs)
return wrapper(_wrapped)
def parameters(*args):
"""Specify multiple Parameter definitions at once
This can be useful for commands which have a large number of complicated
parameters but aren't good candidates for a Model.
.. code-block:: python
@parameter(**params[cmd1][param1])
@parameter(**params[cmd1][param2])
@parameter(**params[cmd1][param3])
def cmd1(self, **kwargs):
pass
Can become:
.. code-block:: python
@parameters(params[cmd1])
def cmd1(self, **kwargs):
pass
Args:
*args (iterable): Positional arguments
The first (and only) positional argument must be a list containing
dictionaries that describe parameters.
Returns:
func: The decorated function
"""
if len(args) == 1:
return functools.partial(parameters, args[0])
elif len(args) != 2:
raise PluginParamError("@parameters takes a single argument")
if not isinstance(args[1], types.FunctionType):
raise PluginParamError("@parameters must be applied to a function")
try:
for param in args[0]:
parameter(args[1], **param)
except TypeError:
raise PluginParamError("@parameters argument must be iterable of dictionaries")
@wrapt.decorator(enabled=_wrap_functions)
def wrapper(_double_wrapped, _, _args, _kwargs):
return _double_wrapped(*_args, **_kwargs)
return wrapper(args[1])
def _update_func_command(func_command, generated_command):
"""Updates the current function's command with info, (will not override plugin_params)"""
func_command.name = generated_command.name
func_command.description = generated_command.description
func_command.command_type = generated_command.command_type
func_command.output_type = generated_command.output_type
func_command.schema = generated_command.schema
func_command.form = generated_command.form
func_command.template = generated_command.template
func_command.icon_name = generated_command.icon_name
def _generate_command_from_function(func):
"""Generates a Command from a function. Uses first line of pydoc as the description."""
# Required for Python 2/3 compatibility
if hasattr(func, "func_name"):
command_name = func.func_name
else:
command_name = func.__name__
# Required for Python 2/3 compatibility
if hasattr(func, "func_doc"):
docstring = func.func_doc
else:
docstring = func.__doc__
return Command(
name=command_name,
description=docstring.split("\n")[0] if docstring else None,
parameters=_generate_params_from_function(func),
)
def _generate_params_from_function(func):
"""Generate Parameters from function arguments.
Will set the Parameter key, default value, and optional value."""
parameters_to_return = []
code = six.get_function_code(func)
function_arguments = list(code.co_varnames or [])[: code.co_argcount]
function_defaults = list(six.get_function_defaults(func) or [])
while len(function_defaults) != len(function_arguments):
function_defaults.insert(0, None)
for index, param_name in enumerate(function_arguments):
# Skip Self or Class reference
if index == 0 and isinstance(func, types.FunctionType):
continue
default = function_defaults[index]
optional = False if default is None else True
parameters_to_return.append(
Parameter(key=param_name, default=default, optional=optional)
)
return parameters_to_return
def _generate_nested_params(model_class):
"""Generates Nested Parameters from a Model Class"""
parameters_to_return = []
for parameter_definition in model_class.parameters:
key = parameter_definition.key
parameter_type = parameter_definition.type
multi = parameter_definition.multi
display_name = parameter_definition.display_name
optional = parameter_definition.optional
default = parameter_definition.default
description = parameter_definition.description
nullable = parameter_definition.nullable
maximum = parameter_definition.maximum
minimum = parameter_definition.minimum
regex = parameter_definition.regex
choices = _format_choices(parameter_definition.choices)
nested_parameters = []
if parameter_definition.parameters:
parameter_type = "Dictionary"
for nested_class in parameter_definition.parameters:
nested_parameters = _generate_nested_params(nested_class)
parameters_to_return.append(
Parameter(
key=key,
type=parameter_type,
multi=multi,
display_name=display_name,
optional=optional,
default=default,
description=description,
choices=choices,
parameters=nested_parameters,
nullable=nullable,
maximum=maximum,
minimum=minimum,
regex=regex,
)
)
return parameters_to_return
def _resolve_display_modifiers(
wrapped, command_name, schema=None, form=None, template=None
):
def _load_from_url(url):
return json.loads(requests.get(url).text)
def _load_from_path(path):
current_dir = os.path.dirname(inspect.getfile(wrapped))
file_path = os.path.abspath(os.path.join(current_dir, path))
with open(file_path, "r") as definition_file:
return definition_file.read()
resolved = {}
for key, value in {"schema": schema, "form": form, "template": template}.items():
if isinstance(value, six.string_types):
try:
if value.startswith("http"):
resolved[key] = _load_from_url(value)
elif value.startswith("/") or value.startswith("."):
loaded_value = _load_from_path(value)
resolved[key] = (
loaded_value if key == "template" else json.loads(loaded_value)
)
elif key == "template":
resolved[key] = value
else:
raise PluginParamError(
"%s specified for command '%s' was not a "
"definition, file path, or URL" % (key, command_name)
)
except Exception as ex:
raise PluginParamError(
"Error reading %s definition from '%s' for command "
"'%s': %s" % (key, value, command_name, ex)
)
elif value is None or (key in ["schema", "form"] and isinstance(value, dict)):
resolved[key] = value
elif key == "form" and isinstance(value, list):
resolved[key] = {"type": "fieldset", "items": value}
else:
raise PluginParamError(
"%s specified for command '%s' was not a definition, "
"file path, or URL" % (key, command_name)
)
return resolved
def _format_type(param_type):
if param_type == str:
return "String"
elif param_type == int:
return "Integer"
elif param_type == float:
return "Float"
elif param_type == bool:
return "Boolean"
elif param_type == dict:
return "Dictionary"
else:
return param_type
def _format_choices(choices):
def determine_display(display_value):
if isinstance(display_value, six.string_types):
return "typeahead"
return "select" if len(display_value) <= 50 else "typeahead"
def determine_type(type_value):
if isinstance(type_value, (list, dict)):
return "static"
elif type_value.startswith("http"):
return "url"
else:
return "command"
if not choices:
return None
if not isinstance(choices, (list, six.string_types, dict)):
raise PluginParamError(
"Invalid 'choices' provided. Must be a list, dictionary or string."
)
elif isinstance(choices, dict):
if not choices.get("value"):
raise PluginParamError(
"No 'value' provided for choices. You must at least "
"provide valid values."
)
value = choices.get("value")
display = choices.get("display", determine_display(value))
choice_type = choices.get("type")
strict = choices.get("strict", True)
if choice_type is None:
choice_type = determine_type(value)
elif choice_type not in Choices.TYPES:
raise PluginParamError(
"Invalid choices type '%s' - Valid type options are %s"
% (choice_type, Choices.TYPES)
)
else:
if (
(
choice_type == "command"
and not isinstance(value, (six.string_types, dict))
)
or (choice_type == "url" and not isinstance(value, six.string_types))
or (choice_type == "static" and not isinstance(value, (list, dict)))
):
allowed_types = {
"command": "('string', 'dictionary')",
"url": "('string')",
"static": "('list', 'dictionary)",
}
raise PluginParamError(
"Invalid choices value type '%s' - Valid value types for "
"choice type '%s' are %s"
% (type(value), choice_type, allowed_types[choice_type])
)
if display not in Choices.DISPLAYS:
raise PluginParamError(
"Invalid choices display '%s' - Valid display options are %s"
% (display, Choices.DISPLAYS)
)
else:
value = choices
display = determine_display(value)
choice_type = determine_type(value)
strict = True
# Now parse out type-specific aspects
unparsed_value = ""
try:
if choice_type == "command":
if isinstance(value, six.string_types):
unparsed_value = value
else:
unparsed_value = value["command"]
details = parse(unparsed_value, parse_as="func")
elif choice_type == "url":
unparsed_value = value
details = parse(unparsed_value, parse_as="url")
else:
if isinstance(value, dict):
unparsed_value = choices.get("key_reference")
if unparsed_value is None:
raise PluginParamError(
"Specifying a static choices dictionary requires a "
'"key_reference" field with a reference to another '
'parameter ("key_reference": "${param_key}")'
)
details = {"key_reference": parse(unparsed_value, parse_as="reference")}
else:
details = {}
except ParseError:
raise PluginParamError(
"Invalid choices definition - Unable to parse '%s'" % unparsed_value
)
return Choices(
type=choice_type, display=display, value=value, strict=strict, details=details
)
# Alias the old names for compatibility
command_registrar = system
plugin_param = parameter
register = command