diff --git a/girder_worker_utils/command.py b/girder_worker_utils/command.py new file mode 100644 index 0000000..f2cc8d6 --- /dev/null +++ b/girder_worker_utils/command.py @@ -0,0 +1,76 @@ +import json + +import click + +from .parameter import Parameter + + +class Command(click.Command): + + def __init__(self, *args, **kwargs): + params = kwargs.pop('params', None) or [] + input_params = [] + output_params = [] + other_params = [] + for param in params: + if isinstance(param, Parameter) and param.is_input(): + input_params.append(param) + elif isinstance(param, Parameter) and param.is_output(): + output_params.append(param) + else: + other_params.append(param) + other_params += [ + click.Option(('--item-tasks-json',), is_flag=True, default=False) + ] + kwargs['params'] = input_params + other_params + self._input_params = input_params + self._output_params = output_params + self._other_params = other_params + super(Command, self).__init__(*args, **kwargs) + + def invoke(self, ctx): + if ctx.params.pop('item_tasks_json', False): + return self._print_item_tasks_json(ctx) + return super(Command, self).invoke(ctx) + + def invoke_item_task(self, bindings): + # parse the input bindings to construct an argument list + kwargs = {} + + # We need to loop through all of the input_params to get default values + # because we are bypassing the part of click that does this by calling + # ctx.invoke directly. This is done because there is no way to + # construct CLI argument list from bindings (the name munging logic in + # click is strictly one directional). + # + # Alternatively, we might store the commandline argument name in addition + # to the bound variable name so we *can* generate a valid CLI argument + # list... this could potentially remove some of the custom logic present + # in girder's item_tasks plugin. + with self.make_context(self.name, [], resilient_parsing=True) as ctx: + for param in self._input_params: + kwargs.update(param.get_kwargs_from_input_bindings(bindings, ctx)) + + return ctx.invoke(self.callback, **kwargs) + + def item_tasks_json(self, ctx=None): + spec = { + 'name': self.name, + 'description': self.help or '', + 'inputs': [], + 'outputs': [] + } + for param in self._input_params: + spec['inputs'].append(param.item_tasks_json(ctx)) + for param in self._output_params: + spec['outputs'].append(param.item_tasks_json(ctx)) + return spec + + def make_context(self, info_name, args, **kwargs): + if '--item-tasks-json' in args: + kwargs.setdefault('resilient_parsing', True) + return super(Command, self).make_context(info_name, args, **kwargs) + + def _print_item_tasks_json(self, ctx=None): + spec = self.item_tasks_json(ctx) + click.echo(json.dumps(spec, indent=2)) diff --git a/girder_worker_utils/decorators.py b/girder_worker_utils/decorators.py index ca21847..3d6824a 100644 --- a/girder_worker_utils/decorators.py +++ b/girder_worker_utils/decorators.py @@ -1,121 +1,33 @@ -from inspect import getdoc -try: - from inspect import signature -except ImportError: # pragma: nocover - from funcsigs import signature -import six +import click +from .command import Command +from .input import Input +from .output import Output +from .types import String -class MissingDescriptionException(Exception): - """Raised when a function is missing description decorators.""" - - -class MissingInputException(Exception): - """Raised when a required input is missing.""" - - -def get_description_attribute(func): - """Get the private description attribute from a function.""" - func = getattr(func, 'run', func) - description = getattr(func, '_girder_description', None) - if description is None: - raise MissingDescriptionException('Function is missing description decorators') - return description - - -def argument(name, data_type, *args, **kwargs): - """Describe an argument to a function as a function decorator. - - Additional arguments are passed to the type class constructor. - - :param str name: The parameter name from the function declaration - :param type: A type class derived from ``girder_worker_utils.types.Base`` - """ - if not isinstance(name, six.string_types): - raise TypeError('Expected argument name to be a string') - - if callable(data_type): - data_type = data_type(name, *args, **kwargs) - - def argument_wrapper(func): - func._girder_description = getattr(func, '_girder_description', {}) - args = func._girder_description.setdefault('arguments', []) - sig = signature(func) - - if name not in sig.parameters: - raise ValueError('Invalid argument name "%s"' % name) - - data_type.set_parameter(sig.parameters[name], signature=sig) - args.insert(0, data_type) - - def call_item_task(inputs, outputs={}): - args, kwargs = parse_inputs(func, inputs) - return func(*args, **kwargs) - - def describe(): - return describe_function(func) - - func.call_item_task = call_item_task - func.describe = describe +def task(name=None, cls=Command, **attrs): + def wrapper(func): + cmd_wrapper = click.command(name, cls, **attrs) + func.main = cmd_wrapper(func) + func.call_item_task = func.main.invoke_item_task + func.describe = func.main.item_tasks_json return func + return wrapper - return argument_wrapper - - -def describe_function(func): - """Return a json description from a decorated function.""" - description = get_description_attribute(func) - - inputs = [arg.describe() for arg in description.get('arguments', [])] - spec = { - 'name': description.get('name', func.__name__), - 'inputs': inputs, - 'mode': 'girder_worker' - } - desc = description.get('description', getdoc(func)) - if desc: - spec['description'] = desc - - return spec - - -def get_input_data(arg, input_binding): - """Parse an input binding from a function argument description. - - :param arg: An instantiated type description - :param input_binding: An input binding object - :returns: The parameter value - """ - mode = input_binding.get('mode', 'inline') - if mode == 'inline' and 'data' in input_binding: - value = arg.deserialize(input_binding['data']) - elif mode == 'girder': - value = input_binding.get('id') - else: - raise ValueError('Unhandled input mode') - - arg.validate(value) - return value +def task_input(*param_decls, **attrs): + attrs.setdefault('cls', Input) + attrs.setdefault('type', String()) + return click.option(*param_decls, **attrs) -def parse_inputs(func, inputs): - """Parse an object of input bindings from item_tasks. - :param func: The task function - :param dict inputs: The input task bindings object - :returns: args and kwargs objects to call the function with - """ - description = get_description_attribute(func) - arguments = description.get('arguments', []) - args = [] - kwargs = {} - for arg in arguments: - desc = arg.describe() - input_id = desc['id'] - name = desc['name'] - if input_id not in inputs and not arg.has_default(): - raise MissingInputException('Required input "%s" not provided' % name) - if input_id in inputs: - kwargs[name] = get_input_data(arg, inputs[input_id]) - return args, kwargs +def task_output(*param_decls, **attrs): + decls = [] + for decl in param_decls: + if not decl.startswith('-'): + decl = '--' + decl + decls.append(decl) + attrs.setdefault('cls', Output) + attrs.setdefault('type', String()) + return click.argument(*decls, **attrs) diff --git a/girder_worker_utils/input.py b/girder_worker_utils/input.py new file mode 100644 index 0000000..0c4679a --- /dev/null +++ b/girder_worker_utils/input.py @@ -0,0 +1,67 @@ +try: + from inspect import signature +except ImportError: + from funcsigs import signature + +from click.exceptions import BadParameter + +from .parameter import Parameter + + +class Input(Parameter): + _item_tasks_type = 'input' + + # TODO: Currently, the context argument exists entirely to provide the function + # signature for providing default values. Click on it's own expects that + # defaults are provided by the @option decorators. It could be that parsing + # the function signature for default values is a bad idea, and we should + # remove the context argument here. + # + # As an additional implementation note, there is some confusion as to + # exactly methods need to have access to the context. In most cases, + # click uses the context optionally inside it's API. In the item_tasks + # use case, it is complicated because we cannot create a context (which + # requires the argument list) before parsing the input bindings. This + # will almost certainly cause pain down the road as code is created that + # *needs* access to the context for special processing. + def item_tasks_json(self, ctx=None): + spec = super(Input, self).item_tasks_json(ctx) + + # get default from the decorator + default = self.get_default(ctx) + + # get default from the function definition + if ctx and self.default is None: + default = self._get_default_from_function(ctx.command) + + if default is not None: + spec['default'] = { + 'data': default + } + spec.update(self.type.item_tasks_json(self, ctx)) + return spec + + def get_kwargs_from_input_bindings(self, bindings, ctx): + value = self.default + if self.name in bindings: + binding = bindings[self.name] + if not isinstance(binding, dict) or 'data' not in binding: + raise BadParameter('Invalid input binding', ctx=ctx, param=self) + value = binding['data'] + + if value is None: + return {} + + return {self.name: value} + + def _get_default_from_function(self, command): + if command is None: + return + func = command.callback + sig = signature(func) + if self.name not in sig.parameters: + return + param = sig.parameters[self.name] + if param.default == param.empty: + return + return param.default diff --git a/girder_worker_utils/output.py b/girder_worker_utils/output.py new file mode 100644 index 0000000..dfcc387 --- /dev/null +++ b/girder_worker_utils/output.py @@ -0,0 +1,5 @@ +from .parameter import Parameter + + +class Output(Parameter): + _item_tasks_type = 'output' diff --git a/girder_worker_utils/parameter.py b/girder_worker_utils/parameter.py new file mode 100644 index 0000000..eff266e --- /dev/null +++ b/girder_worker_utils/parameter.py @@ -0,0 +1,20 @@ +import click + + +class Parameter(click.Option): + _item_tasks_type = None + + def item_tasks_json(self, command=None): + spec = { + 'id': self.name, + 'name': self.name, + 'description': self.help or '' + } + spec.update(self.type.item_tasks_json(self)) + return spec + + def is_input(self): + return self._item_tasks_type == 'input' + + def is_output(self): + return self._item_tasks_type == 'output' diff --git a/girder_worker_utils/tests/decorators_test.py b/girder_worker_utils/tests/decorators_test.py index 85a9c78..d3eb2c0 100644 --- a/girder_worker_utils/tests/decorators_test.py +++ b/girder_worker_utils/tests/decorators_test.py @@ -1,11 +1,12 @@ import pytest -from girder_worker_utils import decorators from girder_worker_utils import types -from girder_worker_utils.decorators import argument +from girder_worker_utils.decorators import task, task_input, task_output -@argument('n', types.Integer, help='The element to return') +@task() +@task_input('-n', type=types.Integer(), help='The element to return') +@task_output('value', type=types.Integer()) def fibonacci(n): """Compute a fibonacci number.""" if n <= 2: @@ -13,18 +14,21 @@ def fibonacci(n): return fibonacci(n - 1) + fibonacci(n - 2) -@argument('val', types.String, help='The value to return') +@task() +@task_input('--val', help='The value to return') +@task_output('noop_value') def keyword_func(val='test'): """Return a value.""" return val -@argument('arg1', types.String) -@argument('arg2', types.StringChoice, choices=('a', 'b')) -@argument('kwarg1', types.StringVector) -@argument('kwarg2', types.Number, min=0, max=10) -@argument('kwarg3', types.NumberMultichoice, choices=(1, 2, 3, 4, 5)) -def complex_func(arg1, arg2, kwarg1=('one',), kwarg2=4, kwarg3=(1, 2)): +@task() +@task_input('--arg1') +@task_input('--arg2', type=types.Choice(('a', 'b'))) +@task_input('--kwarg1', nargs=2) +@task_input('--kwarg2', type=types.Integer(min=0, max=10)) +@task_input('--kwarg3', type=types.Choice((1, 2, 3, 4, 5)), multiple=True) +def complex_func(arg1, arg2, kwarg1=('one', 'two'), kwarg2=4, kwarg3=(1, 2)): return { 'arg1': arg1, 'arg2': arg2, @@ -34,12 +38,6 @@ def complex_func(arg1, arg2, kwarg1=('one',), kwarg2=4, kwarg3=(1, 2)): } -@argument('item', types.GirderItem) -@argument('folder', types.GirderFolder) -def girder_types_func(item, folder): - return item, folder - - def test_positional_argument(): desc = fibonacci.describe() assert len(desc['inputs']) == 1 @@ -52,7 +50,7 @@ def test_positional_argument(): 'The element to return' assert fibonacci.call_item_task({'n': {'data': 10}}) == 55 - with pytest.raises(decorators.MissingInputException): + with pytest.raises(TypeError): fibonacci.call_item_task({}) @@ -82,25 +80,12 @@ def test_multiple_arguments(): assert desc['inputs'][3]['name'] == 'kwarg2' assert desc['inputs'][4]['name'] == 'kwarg3' - with pytest.raises(decorators.MissingInputException): + with pytest.raises(TypeError): complex_func.call_item_task({}) - with pytest.raises(decorators.MissingInputException): - complex_func.call_item_task({ - 'arg1': {'data': 'value'} - }) - - with pytest.raises(ValueError): - complex_func.call_item_task({ - 'arg1': {'data': 'value'}, - 'arg2': {'data': 'invalid'} - }) - with pytest.raises(TypeError): complex_func.call_item_task({ - 'arg1': {'data': 'value'}, - 'arg2': {'data': 'a'}, - 'kwarg2': {'data': 'foo'} + 'arg1': {'data': 'value'} }) assert complex_func.call_item_task({ @@ -109,7 +94,7 @@ def test_multiple_arguments(): }) == { 'arg1': 'value', 'arg2': 'a', - 'kwarg1': ('one',), + 'kwarg1': ('one', 'two'), 'kwarg2': 4, 'kwarg3': (1, 2) } @@ -117,7 +102,7 @@ def test_multiple_arguments(): assert complex_func.call_item_task({ 'arg1': {'data': 'value'}, 'arg2': {'data': 'b'}, - 'kwarg1': {'data': 'one,two'}, + 'kwarg1': {'data': ['one', 'two']}, 'kwarg2': {'data': 10}, 'kwarg3': {'data': (1, 4)} }) == { @@ -127,48 +112,3 @@ def test_multiple_arguments(): 'kwarg2': 10, 'kwarg3': (1, 4) } - - -def test_girder_input_mode(): - item, folder = girder_types_func.call_item_task({ - 'item': { - 'mode': 'girder', - 'id': 'itemid', - 'resource_type': 'item', - 'fileName': 'file.txt' - }, - 'folder': { - 'mode': 'girder', - 'id': 'folderid', - 'resource_type': 'folder' - } - }) - - assert item == 'itemid' - assert folder == 'folderid' - - -def test_missing_description_exception(): - def func(): - pass - - with pytest.raises(decorators.MissingDescriptionException): - decorators.get_description_attribute(func) - - -def test_argument_name_not_string(): - with pytest.raises(TypeError): - argument(0, types.Integer) - - -def test_argument_name_not_a_parameter(): - with pytest.raises(ValueError): - @argument('notarg', types.Integer) - def func(arg): - pass - - -def test_unhandled_input_binding(): - arg = argument('arg', types.Integer) - with pytest.raises(ValueError): - decorators.get_input_data(arg, {}) diff --git a/girder_worker_utils/tests/types_test.py b/girder_worker_utils/tests/types_test.py deleted file mode 100644 index c9a26d4..0000000 --- a/girder_worker_utils/tests/types_test.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest - -from girder_worker_utils import types - - -def test_serialize_boolean(): - arg = types.Boolean('arg') - assert arg.serialize(None) is False - assert arg.serialize(1) is True - - -def test_validate_choice_value(): - arg = types.StringMultichoice('arg') - with pytest.raises(TypeError): - arg.validate('not a list') - - -def test_serialize_integer(): - arg = types.Integer('arg') - assert arg.serialize(1.0) is 1 - - -def test_validate_integer_range(): - arg = types.Integer('arg', min=0, max=10) - with pytest.raises(ValueError): - arg.validate(-1) - - with pytest.raises(ValueError): - arg.validate(11) - - -def test_validate_string(): - arg = types.String('arg') - with pytest.raises(TypeError): - arg.validate(1) - - -def test_validate_number_vector(): - arg = types.NumberVector('arg') - with pytest.raises(TypeError): - arg.validate('not a list') diff --git a/girder_worker_utils/types/__init__.py b/girder_worker_utils/types/__init__.py index a3dd2cb..fa9ed0b 100644 --- a/girder_worker_utils/types/__init__.py +++ b/girder_worker_utils/types/__init__.py @@ -1,31 +1,18 @@ from .boolean import Boolean +from .choice import Choice from .color import Color -from .girder_folder import GirderFolder -from .girder_item import GirderItem -from .integer import Integer -from .number import Number -from .number_choice import NumberChoice -from .number_multichoice import NumberMultichoice -from .number_vector import NumberVector -from .range import Range +from .file import File, Folder, Image +from .number import Float, Integer from .string import String -from .string_choice import StringChoice -from .string_multichoice import StringMultichoice -from .string_vector import StringVector __all__ = ( Boolean, + Choice, Color, - GirderFolder, - GirderItem, + File, + Float, + Folder, + Image, Integer, - Number, - NumberChoice, - NumberMultichoice, - NumberVector, - Range, - String, - StringChoice, - StringMultichoice, - StringVector + String ) diff --git a/girder_worker_utils/types/base.py b/girder_worker_utils/types/base.py deleted file mode 100644 index f213550..0000000 --- a/girder_worker_utils/types/base.py +++ /dev/null @@ -1,67 +0,0 @@ -from copy import deepcopy - - -class Base(object): - """Define an abstract type class. - - This class defines the interface expected by the functions in - :py:mod:`girder_worker.describe`. All autodescribing parameter - types should derive from this class. - """ - - #: This is used as a base description object for all types. - description = {} - - def __init__(self, name, **kwargs): - """Construct a task description. - - Subclasses can define additional keyword arguments. - - :param str name: The argument name in the task function. - :param str id: An optional id (defaults to the argument name) - :param str help: A help string (defaults to the function docstring) - """ - self.id = kwargs.get('id', name) - self.name = name - self.help = kwargs.get('help') - - def set_parameter(self, parameter, **kwargs): - """Store a parameter instance from a function signature. - - This method is called internally by description decorators. - - :param parameter: The parameter signature from the task - :type parameter: inspect.Parameter - """ - self.parameter = parameter - - def has_default(self): - """Return true if the parameter has a default value.""" - return self.parameter.default is not self.parameter.empty - - def describe(self): - """Return a type description serialization.""" - copy = deepcopy(self.description) - copy.setdefault('id', self.id) - copy.setdefault('name', self.name) - if self.has_default(): - copy.setdefault('default', { - 'data': self.serialize(self.parameter.default) - }) - if self.help is not None: - copy['description'] = self.help - return copy - - def serialize(self, value): - """Serialize a python value into a format expected by item_tasks.""" - return value - - def deserialize(self, value): - """Deserialize a python value from a format provided by item_tasks.""" - return value - - def validate(self, value): - """Validate a parameter value. - - :raises Exception: if the value is not valid - """ diff --git a/girder_worker_utils/types/boolean.py b/girder_worker_utils/types/boolean.py index 896f033..0dc019e 100644 --- a/girder_worker_utils/types/boolean.py +++ b/girder_worker_utils/types/boolean.py @@ -1,18 +1,8 @@ -from .base import Base +import click -class Boolean(Base): - """Define a boolean task parameter. - - >>> @argument('debug', types.Boolean) - ... def func(debug=False): - ... pass - """ - - description = { - 'type': 'boolean', - 'description': 'Provide a boolean value' - } - - def serialize(self, value): - return bool(value) +class Boolean(click.types.BoolParamType): + def item_tasks_json(self, param, ctx=None): + return { + 'type': 'boolean' + } diff --git a/girder_worker_utils/types/choice.py b/girder_worker_utils/types/choice.py index 462d145..04c51b8 100644 --- a/girder_worker_utils/types/choice.py +++ b/girder_worker_utils/types/choice.py @@ -1,40 +1,13 @@ -from .base import Base +import click -class Choice(Base): - """Define base functionality for multi choice parameter.""" +class Choice(click.Choice): - #: The explicit type provided by the subclass - paramType = None - - #: Whether the parameter accepts multiple values from the choices - multiple = False - - def __init__(self, *args, **kwargs): - """Construct a choice parameter. - - :param list choices: A list of valid values. - """ - self.choices = kwargs.get('choices', []) - super(Choice, self).__init__(*args, **kwargs) - - def describe(self, *args, **kwargs): - desc = super(Choice, self).describe(*args, **kwargs) - desc['type'] = self.paramType - desc['values'] = self.choices - desc['description'] = 'Choose from a list of values' - return desc - - def _validate_one(self, value): - if value not in self.choices: - raise ValueError('Invalid value provided for "%s"' % self.name) - - def validate(self, value): - if not self.multiple: - value = [value] - - if not isinstance(value, (list, tuple)): - raise TypeError( - 'Expected a list or tuple for "%s"' % self.name) - for v in value: - self._validate_one(v) + def item_tasks_json(self, param, ctx=None): + multiple = '' + if param.multiple: + multiple = '-multiple' + return { + 'type': 'string-choice' + multiple, + 'values': self.choices + } diff --git a/girder_worker_utils/types/color.py b/girder_worker_utils/types/color.py index be7d6b0..986794a 100644 --- a/girder_worker_utils/types/color.py +++ b/girder_worker_utils/types/color.py @@ -1,17 +1,8 @@ -from .base import Base +import click -class Color(Base): - """Define a task parameter expecting a color value. - - >>> @argument('background', types.Color) - ... def func(background): - ... pass - """ - - description = { - 'type': 'color', - 'description': 'Provide a color value' - } - - # TODO: handle normalization and validation +class Color(click.types.StringParamType): + def item_tasks_json(self, param, ctx=None): + return { + 'type': 'color' + } diff --git a/girder_worker_utils/types/file.py b/girder_worker_utils/types/file.py new file mode 100644 index 0000000..ed0337c --- /dev/null +++ b/girder_worker_utils/types/file.py @@ -0,0 +1,28 @@ +import click + + +class File(click.types.File): + def item_tasks_json(self, param, ctx=None): + widget = 'file' + if param.is_output(): + widget = 'new-file' + return { + 'type': widget + } + + +class Image(click.types.File): + def item_tasks_json(self, param, ctx=None): + return { + 'type': 'image' + } + + +class Folder(click.types.Path): + def item_tasks_json(self, param, ctx=None): + widget = 'directory' + if param.is_output(): + widget = 'new-folder' + return { + 'type': widget + } diff --git a/girder_worker_utils/types/girder.py b/girder_worker_utils/types/girder.py deleted file mode 100644 index 866165c..0000000 --- a/girder_worker_utils/types/girder.py +++ /dev/null @@ -1,5 +0,0 @@ -from .base import Base - - -class Girder(Base): - """Define a base parameter type representing a Girder Id.""" diff --git a/girder_worker_utils/types/girder_folder.py b/girder_worker_utils/types/girder_folder.py deleted file mode 100644 index a07c6f3..0000000 --- a/girder_worker_utils/types/girder_folder.py +++ /dev/null @@ -1,11 +0,0 @@ -from .girder import Girder - - -class GirderFolder(Girder): - """Define a parameter representing a girder folder id.""" - - def describe(self, **kwargs): - desc = super(GirderFolder, self).describe(**kwargs) - desc['type'] = 'directory' - desc['description'] = self.help or 'Select a folder' - return desc diff --git a/girder_worker_utils/types/girder_item.py b/girder_worker_utils/types/girder_item.py deleted file mode 100644 index 9937bb9..0000000 --- a/girder_worker_utils/types/girder_item.py +++ /dev/null @@ -1,11 +0,0 @@ -from .girder import Girder - - -class GirderItem(Girder): - """Define a parameter representing a girder item id.""" - - def describe(self, **kwargs): - desc = super(GirderItem, self).describe(**kwargs) - desc['type'] = 'file' - desc['description'] = self.help or 'Select an item' - return desc diff --git a/girder_worker_utils/types/integer.py b/girder_worker_utils/types/integer.py deleted file mode 100644 index 17aa943..0000000 --- a/girder_worker_utils/types/integer.py +++ /dev/null @@ -1,20 +0,0 @@ -from .number import Number - - -class Integer(Number): - """Define an integer task parameter. - - >>> @argument('iterations', types.Integer) - ... def func(iterations=3): - ... pass - """ - - paramType = 'integer' - - def __init__(self, *args, **kwargs): - kwargs['step'] = 1 - super(Integer, self).__init__(*args, **kwargs) - - def serialize(self, value): - value = super(Integer, self).serialize(value) - return int(value) diff --git a/girder_worker_utils/types/number.py b/girder_worker_utils/types/number.py index 0ee9ea7..f7ccecd 100644 --- a/girder_worker_utils/types/number.py +++ b/girder_worker_utils/types/number.py @@ -1,67 +1,32 @@ -import numbers - -from .base import Base - - -class Number(Base): - """Define a numeric parameter type optionally in a given range. - - Values accepted by this parameter can be any numeric value. - If min/max/step are provided, then the values must be a float - or int. - - >>> @argument('value', types.Number, min=10, max=100, step=10) - ... def func(value): - ... pass - """ - - paramType = 'number' - - def __init__(self, *args, **kwargs): - """Construct a new numeric parameter type. - - :param float min: The minimum valid value - :param float max: The maximum valid value - :param float step: The resolution of valid values - """ - super(Number, self).__init__(*args, **kwargs) - self.min = kwargs.get('min') - self.max = kwargs.get('max') - self.step = kwargs.get('step') - - def describe(self, **kwargs): - desc = super(Number, self).describe(**kwargs) - - if self.min is not None: - desc['min'] = self.min - if self.max is not None: - desc['max'] = self.max - if self.step is not None: - desc['step'] = self.step - - desc['type'] = self.paramType - desc['description'] = self.help or 'Select a number' - return desc - - def validate(self, value): - if not isinstance(value, numbers.Number): - raise TypeError('Expected a number for parameter "%s"' % self.name) - - if self.min is not None and value < self.min: - raise ValueError('Expected %s <= %s' % (str(self.min), str(value))) - - if self.max is not None and value > self.max: - raise ValueError('Expected %s >= %s' % (str(self.max), str(value))) - - def serialize(self, value): - if self.step is not None: - n = round(float(value) / self.step) - value = n * self.step - return value - - def deserialize(self, value): - try: - value = float(value) - except ValueError: - pass - return value +import click + + +class Integer(click.types.IntRange): + def __init__(self, widget='number', step=1, **kwargs): + self.widget = widget + self.step = step + super(Integer, self).__init__(**kwargs) + + def item_tasks_json(self, param, ctx=None): + widget = self.widget + if param.nargs > 1: + widget = 'number-vector' + return { + 'type': widget, + 'min': self.min, + 'max': self.max, + 'step': self.step + } + + +# click.FloatRange is created in click master, but not released. +# We could consider backporting it to support min/max arguments +# and slider widgets. +class Float(click.types.FloatParamType): + def item_tasks_json(self, param, ctx=None): + widget = 'number' + if param.nargs > 1: + widget = 'number-vector' + return { + 'type': widget + } diff --git a/girder_worker_utils/types/number_choice.py b/girder_worker_utils/types/number_choice.py deleted file mode 100644 index 377b856..0000000 --- a/girder_worker_utils/types/number_choice.py +++ /dev/null @@ -1,12 +0,0 @@ -from .choice import Choice - - -class NumberChoice(Choice): - """Define a numeric parameter with a set of valid values. - - >>> @argument('address', types.NumberChoice, choices=(5, 10, 15)) - ... def func(address): - ... pass - """ - - paramType = 'number-enumeration' diff --git a/girder_worker_utils/types/number_multichoice.py b/girder_worker_utils/types/number_multichoice.py deleted file mode 100644 index a0bc95d..0000000 --- a/girder_worker_utils/types/number_multichoice.py +++ /dev/null @@ -1,16 +0,0 @@ -from .choice import Choice - - -class NumberMultichoice(Choice): - """Define a multichose numeric parameter type. - - Values of this type are iterable sequences of numbers - all of which must be an element of a predefined set. - - >>> @argument('images', types.NumberMultichoice, choices=(5, 10, 15)) - ... def func(images=(5, 10)): - ... pass - """ - - paramType = 'number-enumeration-multiple' - multiple = True diff --git a/girder_worker_utils/types/number_vector.py b/girder_worker_utils/types/number_vector.py deleted file mode 100644 index f9ed0e0..0000000 --- a/girder_worker_utils/types/number_vector.py +++ /dev/null @@ -1,14 +0,0 @@ -from .number import Number -from .vector import Vector - - -class NumberVector(Vector): - """Define a parameter accepting a list of numbers. - - >>> @argument('value', types.NumberVector, min=10, max=100, step=10) - ... def func(value=(10, 11)): - ... pass - """ - - paramType = 'number-vector' - elementClass = Number diff --git a/girder_worker_utils/types/range.py b/girder_worker_utils/types/range.py deleted file mode 100644 index 5081014..0000000 --- a/girder_worker_utils/types/range.py +++ /dev/null @@ -1,12 +0,0 @@ -from .number import Number - - -class Range(Number): - """Define numeric parameter with valid values in a given range. - - >>> @argument('value', types.Range, min=10, max=100, step=10) - ... def func(value): - ... pass - """ - - paramType = 'range' diff --git a/girder_worker_utils/types/string.py b/girder_worker_utils/types/string.py index a5b11f0..57704c6 100644 --- a/girder_worker_utils/types/string.py +++ b/girder_worker_utils/types/string.py @@ -1,21 +1,8 @@ -import six +import click -from .base import Base - -class String(Base): - """Define a parameter that can be an arbitrary string. - - >>> @argument('person', types.String) - ... def func(person='eve'): - ... pass - """ - - description = { - 'type': 'string', - 'description': 'Provide a string value' - } - - def validate(self, value): - if not isinstance(value, six.string_types): - raise TypeError('Expected a string value for "%s"' % self.name) +class String(click.types.StringParamType): + def item_tasks_json(self, param, ctx=None): + return { + 'type': 'string' + } diff --git a/girder_worker_utils/types/string_choice.py b/girder_worker_utils/types/string_choice.py deleted file mode 100644 index 4397f61..0000000 --- a/girder_worker_utils/types/string_choice.py +++ /dev/null @@ -1,12 +0,0 @@ -from .choice import Choice - - -class StringChoice(Choice): - """Define a string parameter with a list of valid values. - - >>> @argument('person', types.StringChoice, choices=('alice', 'bob', 'charlie')) - ... def func(person): - ... pass - """ - - paramType = 'string-enumeration' diff --git a/girder_worker_utils/types/string_multichoice.py b/girder_worker_utils/types/string_multichoice.py deleted file mode 100644 index ce0fe47..0000000 --- a/girder_worker_utils/types/string_multichoice.py +++ /dev/null @@ -1,16 +0,0 @@ -from .choice import Choice - - -class StringMultichoice(Choice): - """Define a multichose string parameter type. - - Values of this type are iterable sequences of strings - all of which must be an element of a predefined set. - - >>> @argument('people', types.StringMultichoice, choices=('alice', 'bob', 'charlie')) - ... def func(people=('alice', 'bob')): - ... pass - """ - - paramType = 'string-enumeration-multiple' - multiple = True diff --git a/girder_worker_utils/types/string_vector.py b/girder_worker_utils/types/string_vector.py deleted file mode 100644 index bd8d5a5..0000000 --- a/girder_worker_utils/types/string_vector.py +++ /dev/null @@ -1,14 +0,0 @@ -from .string import String -from .vector import Vector - - -class StringVector(Vector): - """Define a parameter which takes a list of strings. - - >>> @argument('people', types.StringVector) - ... def func(people=('alice', 'bob')): - ... pass - """ - - paramType = 'string-vector' - elementClass = String diff --git a/girder_worker_utils/types/vector.py b/girder_worker_utils/types/vector.py deleted file mode 100644 index 7d68332..0000000 --- a/girder_worker_utils/types/vector.py +++ /dev/null @@ -1,45 +0,0 @@ -import six - -from .base import Base - - -class Vector(Base): - """Define a base class that accepts an iterable object.""" - - #: The explicit type provided by the subclass - paramType = None - - #: The class of the elements of the vector - elementClass = None - - #: A list seperator for serialization - seperator = ',' - - def __init__(self, *args, **kwargs): - if self.paramType is None: # pragma: nocover - raise NotImplementedError('Subclasses should define paramType') - - if self.elementClass is None: # pragma: nocover - raise NotImplementedError('Subclasses should define elementClass') - - self.element = self.elementClass(*args, **kwargs) - super(Vector, self).__init__(*args, **kwargs) - - def describe(self, *args, **kwargs): - desc = super(Vector, self).describe(*args, **kwargs) - desc['type'] = self.paramType - desc['description'] = 'Provide a list of values' - return desc - - def validate(self, value): - if not isinstance(value, (list, tuple)): - raise TypeError('Expected a list or tuple for "%s"' % self.name) - - for elementValue in value: - self.element.validate(elementValue) - - def deserialize(self, value): - if isinstance(value, six.string_types): - value = value.split(self.seperator) - - return [self.element.deserialize(v) for v in value] diff --git a/requirements.in b/requirements.in index 23a8c80..4e30c00 100644 --- a/requirements.in +++ b/requirements.in @@ -1,3 +1,4 @@ +click funcsigs ; python_version < '3.5' girder-client>=2 jsonpickle