From 3ed54b358d504e9280dd83359d8c423c7433fef8 Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Fri, 29 Mar 2024 17:00:49 -0400 Subject: [PATCH 1/3] major refactoring to support `views[].metadata.appConfiguration` ... * updated to `mmif-python==1.0.11` * parameter type casting now happens in `ClamsApp` class, instead of on the restifier wrapper layer (updated all test cases for this) * all "raw" parameters passed to the app are now assumed to be a list of string values, directly from `flask.request.args.to_dict(flat=False)` --- clams/app/__init__.py | 182 +++++++++++++++++++++++++++++++------- clams/restify/__init__.py | 116 ++---------------------- requirements.txt | 2 +- tests/test_clamsapp.py | 41 +++++---- 4 files changed, 177 insertions(+), 164 deletions(-) diff --git a/clams/app/__init__.py b/clams/app/__init__.py index 1a2e0a0..73ef844 100644 --- a/clams/app/__init__.py +++ b/clams/app/__init__.py @@ -9,10 +9,10 @@ __all__ = ['ClamsApp'] -from typing import Union, Any, Optional +from typing import Union, Any, Optional, Dict, List, Iterable from mmif import Mmif, Document, DocumentTypes, View -from clams.appmetadata import AppMetadata +from clams.appmetadata import AppMetadata, RuntimeParameter, real_valued_primitives logging.basicConfig( level=logging.WARNING, @@ -29,10 +29,10 @@ class ClamsApp(ABC): # A set of "universal runtime parameters that can be used for both GET and POST anytime". # The behavioral changes based on these parameters must be implemented on the SDK level. universal_parameters = [ - { + RuntimeParameter(**{ 'name': 'pretty', 'type': 'boolean', 'choices': None, 'default': False, 'multivalued': False, 'description': 'The JSON body of the HTTP response will be re-formatted with 2-space indentation', - }, + }), ] # this key is used to store users' raw input params in the parameter dict # even after "refinement" (i.e., casting to proper data types) @@ -42,24 +42,22 @@ def __init__(self): self.metadata: AppMetadata = self._load_appmetadata() super().__init__() # data type specification for common parameters - python_type = {"boolean": bool, "number": float, "integer": int, "string": str, "map": dict} - self.metadata_param_spec = {} - self.annotate_param_spec = {} for param in ClamsApp.universal_parameters: - self.metadata.add_parameter(**param) - self.metadata_param_spec[param['name']] = (python_type[param['type']], param.get('multivalued', False)) - for param_spec in self.metadata.parameters: - self.annotate_param_spec[param_spec.name] = (python_type[param_spec.type], param_spec.multivalued) + self.metadata.parameters.append(param) + self.metadata_param_caster = ParameterCaster(ClamsApp.universal_parameters) # pytype: disable=wrong-arg-types + self.annotate_param_caster = ParameterCaster(self.metadata.parameters) # pytype: disable=wrong-arg-types self.logger = logging.getLogger(self.metadata.identifier) - def appmetadata(self, **kwargs) -> str: + def appmetadata(self, **kwargs: List[str]) -> str: """ A public method to get metadata for this app as a string. :return: Serialized JSON string of the metadata """ - pretty = kwargs.pop('pretty') if 'pretty' in kwargs else False + # cast only, no refinement + casted = self.metadata_param_caster.cast(kwargs) + pretty = casted.pop('pretty') if 'pretty' in casted else False return self.metadata.jsonify(pretty) def _load_appmetadata(self) -> AppMetadata: @@ -98,7 +96,7 @@ def _appmetadata(self) -> AppMetadata: def _check_mmif_compatibility(target_specver, input_specver): return target_specver.split('.')[:2] == input_specver.split('.')[:2] - def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params) -> str: + def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params: List[str]) -> str: """ A public method to invoke the primary app function. It's essentially a wrapper around :meth:`~clams.app.ClamsApp._annotate` method where some common operations @@ -108,21 +106,22 @@ def annotate(self, mmif: Union[str, dict, Mmif], **runtime_params) -> str: :param runtime_params: An arbitrary set of k-v pairs to configure the app at runtime :return: Serialized JSON string of the output of the app """ - pretty = runtime_params.get('pretty', False) if not isinstance(mmif, Mmif): mmif = Mmif(mmif) issued_warnings = [] for key in runtime_params: - if key not in self.annotate_param_spec: + if key not in self.annotate_param_caster.param_spec: issued_warnings.append(UserWarning(f'An undefined parameter "{key}" (value: "{runtime_params[key]}") is passed')) - refined_params = self._refine_params(**runtime_params) + # this will do casting + refinement altogether + refined = self._refine_params(**runtime_params) + pretty = refined.get('pretty', False) with warnings.catch_warnings(record=True) as ws: - annotated = self._annotate(mmif, **refined_params) + annotated = self._annotate(mmif, **refined) if ws: issued_warnings.extend(ws) if issued_warnings: warnings_view = annotated.new_view() - self.sign_view(warnings_view, refined_params) + self.sign_view(warnings_view, runtime_params) warnings_view.metadata.warnings = issued_warnings return annotated.serialize(pretty=pretty, sanitize=True) @@ -148,7 +147,7 @@ def _annotate(self, mmif: Mmif, _raw_parameters=None, **refined_parameters) -> M """ raise NotImplementedError() - def _refine_params(self, **runtime_params): + def _refine_params(self, **runtime_params: List[str]): """ Method to "fill" the parameter dictionary with default values, when a key-value is not specified in the input. The input map is not really "filled" as a copy of it is returned with addition of default values. @@ -159,19 +158,21 @@ def _refine_params(self, **runtime_params): if self._RAW_PARAMS_KEY in runtime_params: # meaning the dict is already refined, just return it return runtime_params - conf = {} + refined = {} + + casted = self.annotate_param_caster.cast(runtime_params) for parameter in self.metadata.parameters: - if parameter.name in runtime_params: - if parameter.choices and runtime_params[parameter.name] not in parameter.choices: + if parameter.name in casted: + if parameter.choices and casted[parameter.name] not in parameter.choices: raise ValueError(f"Value for parameter \"{parameter.name}\" must be one of {parameter.choices}.") - conf[parameter.name] = runtime_params[parameter.name] + refined[parameter.name] = casted[parameter.name] elif parameter.default is not None: - conf[parameter.name] = parameter.default + refined[parameter.name] = parameter.default else: raise ValueError(f"Cannot find configuration for a required parameter \"{parameter.name}\".") # raw input params are hidden under a special key - conf[self._RAW_PARAMS_KEY] = runtime_params - return conf + refined[self._RAW_PARAMS_KEY] = runtime_params + return refined def get_configuration(self, **runtime_params): warnings.warn("ClamsApp.get_configuration() is deprecated. " @@ -197,15 +198,26 @@ def sign_view(self, view: View, runtime_conf: Optional[dict] = None) -> None: "no longer be optional in the future. Please pass `runtime_params` " "from _annotate() method.", FutureWarning, stacklevel=2) + return view.metadata.app = self.metadata.identifier - if runtime_conf is not None: - if self._RAW_PARAMS_KEY in runtime_conf: - conf = runtime_conf[self._RAW_PARAMS_KEY] - else: - conf = runtime_conf - view.metadata.add_parameters(**{k: str(v) for k, v in conf.items()}) + if self._RAW_PARAMS_KEY in runtime_conf: + for k, v in runtime_conf.items(): + if k == self._RAW_PARAMS_KEY: + for orik, oriv in v.items(): + if orik in self.metadata.parameters and self.metadata.parameters[orik].multivalued: + view.metadata.add_parameter(orik, str(oriv)) + else: + view.metadata.add_parameter(orik, oriv[0]) + view.metadata.add_app_configuration(k, v) + else: + # meaning the parameters directly from flask or argparser and values are in lists + for k, v in runtime_conf.items(): + if k in self.metadata.parameters and self.metadata.parameters[k].multivalued: + view.metadata.add_parameter(k, str(v)) + else: + view.metadata.add_parameter(k, v[0]) - def set_error_view(self, mmif: Union[str, dict, Mmif], runtime_conf: Optional[dict] = None) -> Mmif: + def set_error_view(self, mmif: Union[str, dict, Mmif], **runtime_conf: List[str]) -> Mmif: """ A method to record an error instead of annotation results in the view this app generated. For logging purpose, the runtime parameters used @@ -282,3 +294,105 @@ def open_document_location(document: Union[str, Document], opener: Any = open, * document_file.close() else: raise FileNotFoundError(p.path) + + +class ParameterCaster(object): + KV_DELIMITER = ':' + python_type = {"boolean": bool, "number": float, "integer": int, "string": str, "map": dict} + + """ + A helper class to convert parameters passed by HTTP query strings to + proper python data types. + + :param param_spec: A specification of a data types of parameters + """ + def __init__(self, params: Iterable[RuntimeParameter]): + self.param_spec = {} + for param in params: + self.param_spec[param.name] = (self.python_type[param.type], param.multivalued) + + def cast(self, args: Dict[str, List[str]]) \ + -> Dict[str, Union[real_valued_primitives, List[real_valued_primitives], Dict[str, str]]]: + """ + Given parameter specification, tries to cast values of args to specified Python data types. + Note that this caster deals with query strings, thus all keys and values in the input args are plain strings. + Also note that the caster does not handle "unexpected" parameters came as an input. + Handling (raising an exception or issuing a warning upon receiving) an unexpected runtime parameter + must be done within the app itself. + Thus, when a key is not found in the parameter specifications, it should just pass it as a vanilla string. + + :param args: k-v pairs + :return: A new dictionary of type-casted args, of which keys are always strings (parameter name), + and values are either + 1) a single value of a specified type (multivalued=False) + 2) a list of values of a specified type (multivalued=True) (all duplicates in the input are kept) + 3) a nested string-string dictionary (type=map ⊨ multivalued=True) + With the third case, developers can further process the nested values into a more complex data types or + structures, but that is not in the scope of this Caster class. + """ + casted = {} + for k, vs in args.items(): + assert isinstance(vs, list), f"Expected a list of values for key {k}, but got {vs} of type {type(vs)}" + assert all(isinstance(v, str) for v in vs), f"Expected a list of strings for key {k}, but got {vs} of types {[type(v) for v in vs]}" + if k in self.param_spec: + for v in vs: + valuetype, multivalued = self.param_spec[k] + if multivalued or k not in casted: # effectively only keeps the first value for non-multi params + if valuetype == bool: + v = self.bool_param(v) + elif valuetype == float: + v = self.float_param(v) + elif valuetype == int: + v = self.int_param(v) + elif valuetype == str: + v = self.str_param(v) + elif valuetype == dict: + v = self.kv_param(v) + if multivalued: + if valuetype == dict: + casted.setdefault(k, {}).update(v) + else: + casted.setdefault(k, []).append(v) + else: + casted[k] = v + else: + if len(vs) > 1: + casted[k] = vs + else: + casted[k] = vs[0] + return casted # pytype: disable=bad-return-type + + @staticmethod + def bool_param(value) -> bool: + """ + Helper function to convert string values to bool type. + """ + return False if value in (False, 0, 'False', 'false', '0') else True + + @staticmethod + def float_param(value) -> float: + """ + Helper function to convert string values to float type. + """ + return float(value) + + @staticmethod + def int_param(value) -> int: + """ + Helper function to convert string values to int type. + """ + return int(value) + + @staticmethod + def str_param(value) -> str: + """ + Helper function to convert string values to string type. + """ + return value + + @staticmethod + def kv_param(value) -> Dict[str, str]: + """ + Helper function to convert string values to key-value pair type. + """ + return dict([value.split(ParameterCaster.KV_DELIMITER, 1)]) diff --git a/clams/restify/__init__.py b/clams/restify/__init__.py index 12377d6..92935ec 100644 --- a/clams/restify/__init__.py +++ b/clams/restify/__init__.py @@ -1,12 +1,9 @@ -from typing import Dict, Union, Tuple, List - import jsonschema from flask import Flask, request, Response from flask_restful import Resource, Api from mmif import Mmif from clams.app import ClamsApp -from clams.appmetadata import real_valued_primitives class Restifier(object): @@ -107,8 +104,6 @@ class ClamsHTTPApi(Resource): def __init__(self, cla_instance: ClamsApp): super().__init__() self.cla = cla_instance - self.metadata_param_caster = ParameterCaster(self.cla.metadata_param_spec) # pytype: disable=wrong-arg-types - self.annotate_param_caster = ParameterCaster(self.cla.annotate_param_spec) # pytype: disable=wrong-arg-types @staticmethod def json_to_response(json_str: str, status=200) -> Response: @@ -129,7 +124,7 @@ def get(self) -> Response: :return: Returns app metadata in a HTTP response. """ - return self.json_to_response(self.cla.appmetadata(**self.metadata_param_caster.cast(request.args))) + return self.json_to_response(self.cla.appmetadata(**request.args)) def post(self) -> Response: """ @@ -138,115 +133,16 @@ def post(self) -> Response: :return: Returns MMIF output from a ClamsApp in a HTTP response. """ - raw_data = request.get_data() + raw_data = request.get_data().decode('utf-8') # this will catch duplicate arguments with different values into a list under the key - raw_params = request.args.to_dict(False) + raw_params = request.args.to_dict(flat=False) try: _ = Mmif(raw_data) except jsonschema.exceptions.ValidationError as e: return Response(response="Invalid input data. See below for validation error.\n\n" + str(e), status=500, mimetype='text/plain') try: - return self.json_to_response(self.cla.annotate(raw_data, **self.annotate_param_caster.cast(raw_params))) - except Exception as e: - return self.json_to_response(self.cla.record_error(raw_data, self.annotate_param_caster.cast(raw_params)).serialize(pretty=True), status=500) + return self.json_to_response(self.cla.annotate(raw_data, **raw_params)) + except Exception: + return self.json_to_response(self.cla.record_error(raw_data, **raw_params).serialize(pretty=True), status=500) put = post - - -class ParameterCaster(object): - KV_DELIMITER = ':' - - """ - A helper class to convert parameters passed by HTTP query strings to - proper python data types. - - :param param_spec: A specification of a data types of parameters - """ - def __init__(self, param_spec: Dict[str, Tuple[real_valued_primitives, bool]]): - self.param_spec = param_spec - - def cast(self, args: Dict[str, List[str]]) \ - -> Dict[str, Union[real_valued_primitives, List[real_valued_primitives], Dict[str, str]]]: - """ - Given parameter specification, tries to cast values of args to specified Python data types. - Note that this caster deals with query strings, thus all keys and values in the input args are plain strings. - Also note that the caster does not handle "unexpected" parameters came as an input. - Handling (raising an exception or issuing a warning upon receiving) an unexpected runtime parameter - must be done within the app itself. - Thus, when a key is not found in the parameter specifications, it should just pass it as a vanilla string. - - :param args: k-v pairs - :return: A new dictionary of type-casted args, of which keys are always strings (parameter name), - and values are either - 1) a single value of a specified type (multivalued=False) - 2) a list of values of a specified type (multivalued=True) (all duplicates in the input are kept) - 3) a nested string-string dictionary (type=map ⊨ multivalued=True) - With the third case, developers can further process the nested values into a more complex data types or - structures, but that is not in the scope of this Caster class. - """ - casted = {} - for k, vs in args.items(): - assert isinstance(vs, list), f"Expected a list of values for key {k}, but got {vs} of type {type(vs)}" - assert all(isinstance(v, str) for v in vs), f"Expected a list of strings for key {k}, but got {vs} of types {[type(v) for v in vs]}" - if k in self.param_spec: - for v in vs: - valuetype, multivalued = self.param_spec[k] - if multivalued or k not in casted: # effectively only keeps the first value for non-multi params - if valuetype == bool: - v = self.bool_param(v) - elif valuetype == float: - v = self.float_param(v) - elif valuetype == int: - v = self.int_param(v) - elif valuetype == str: - v = self.str_param(v) - elif valuetype == dict: - v = self.kv_param(v) - if multivalued: - if valuetype == dict: - casted.setdefault(k, {}).update(v) - else: - casted.setdefault(k, []).append(v) - else: - casted[k] = v - else: - if len(vs) > 1: - casted[k] = vs - else: - casted[k] = vs[0] - return casted # pytype: disable=bad-return-type - - @staticmethod - def bool_param(value) -> bool: - """ - Helper function to convert string values to bool type. - """ - return False if value in (False, 0, 'False', 'false', '0') else True - - @staticmethod - def float_param(value) -> float: - """ - Helper function to convert string values to float type. - """ - return float(value) - - @staticmethod - def int_param(value) -> int: - """ - Helper function to convert string values to int type. - """ - return int(value) - - @staticmethod - def str_param(value) -> str: - """ - Helper function to convert string values to string type. - """ - return value - - @staticmethod - def kv_param(value) -> Dict[str, str]: - """ - Helper function to convert string values to key-value pair type. - """ - return dict([value.split(ParameterCaster.KV_DELIMITER, 1)]) diff --git a/requirements.txt b/requirements.txt index 94dd1dc..34148e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -mmif-python==1.0.10 +mmif-python==1.0.11 Flask>=2 Flask-RESTful>=0.3.9 diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py index 00ec4d4..8928b2c 100644 --- a/tests/test_clamsapp.py +++ b/tests/test_clamsapp.py @@ -13,8 +13,8 @@ import clams.app import clams.restify -from clams.appmetadata import AppMetadata, Input -from clams.restify import ParameterCaster +from clams.app import ParameterCaster +from clams.appmetadata import AppMetadata, Input, RuntimeParameter class ExampleInputMMIF(object): @@ -207,7 +207,7 @@ def test_metadata_runtimeparams(self): self.app.metadata.add_more('one', '') # finally for an eye exam - print(self.app.appmetadata(pretty=True)) + print(self.app.appmetadata(pretty=['true'])) @pytest.mark.skip('legacy type version check') def test__check_mmif_compatibility(self): @@ -239,7 +239,7 @@ def test_annotate(self): for v in out_mmif.views: if v.metadata.app == self.app.metadata.identifier: self.assertEqual(len(v.metadata.parameters), 0) # no params were passed when `annotate()` was called - out_mmif = self.app.annotate(self.in_mmif, pretty=False) + out_mmif = self.app.annotate(self.in_mmif, pretty=[str(False)]) out_mmif = Mmif(out_mmif) for v in out_mmif.views: if v.metadata.app == self.app.metadata.identifier: @@ -279,7 +279,7 @@ def test_refine_parameters(self): self.app.metadata.add_parameter('param3', 'third_param', 'boolean', default='f') self.app.metadata.add_parameter('param4', 'fourth_param', 'integer', default='1', choices="1 2 3".split()) self.app.metadata.add_parameter('param5', 'fifth_param', 'number', default='0.5') - dummy_params = {'param1': 'okay', 'non_parameter': 'should be ignored'} + dummy_params = {'param1': ['okay'], 'non_parameter': ['should be ignored']} conf = self.app._refine_params(**dummy_params) # should not refine what's already refined double_refine = self.app._refine_params(**conf) @@ -295,19 +295,19 @@ def test_refine_parameters(self): self.assertEqual(type(conf['param5']), float) with self.assertRaises(ValueError): # because param1 doesn't have a default value and thus a required param - self.app._refine_params(param2='okay') + self.app._refine_params(param2=['okay']) with self.assertRaisesRegexp(ValueError, r'.+must be one of.+'): # because param4 can't be 4, note that param1 is "required" - self.app._refine_params(param1='p1', param4=4) + self.app._refine_params(param1=['p1'], param4=['4']) def test_error_handling(self): - params = {'raise_error': True, 'pretty': True} + params = {'raise_error': 'true', 'pretty': 'true'} in_mmif = Mmif(self.in_mmif) try: out_mmif = self.app.annotate(in_mmif, **params) except Exception as e: - out_mmif_from_str = self.app.set_error_view(self.in_mmif, params) - out_mmif_from_mmif = self.app.set_error_view(in_mmif, params) + out_mmif_from_str = self.app.set_error_view(self.in_mmif, **params) + out_mmif_from_mmif = self.app.set_error_view(in_mmif, **params) self.assertEqual(out_mmif_from_mmif.views, out_mmif_from_str.views) out_mmif = out_mmif_from_str self.assertIsNotNone(out_mmif) @@ -406,16 +406,19 @@ def test_error_on_ill_mmif(self): class TestParameterCaster(unittest.TestCase): def setUp(self) -> None: - self.param_spec = {'str_param': (str, False), - 'number_param': (float, False), - 'int_param': (int, False), - 'bool_param': (bool, False), - 'str_multi_param': (str, True), - 'map_param': (dict, True), - } - + self.params = [] + specs = {'str_param': ('string', False), + 'number_param': ('number', False), + 'int_param': ('integer', False), + 'bool_param': ('boolean', False), + 'str_multi_param': ('string', True), + 'map_param': ('map', True), + } + for name, (t, mv) in specs.items(): + self.params.append(RuntimeParameter(**{'name': name, 'type': t, 'multivalued': mv, 'description': ""})) + def test_cast(self): - caster = ParameterCaster(self.param_spec) + caster = ParameterCaster(self.params) params = { 'str_param': ["a_string"], 'number_param': ["1.11"], From 38631a7e58ed22713c9a21e732624c030dc41577 Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Sun, 31 Mar 2024 17:38:32 -0400 Subject: [PATCH 2/3] bugfixes for setting default values of multivalued params (as lists) --- clams/appmetadata/__init__.py | 20 ++++++++++++++------ tests/test_clamsapp.py | 28 +++++++++++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/clams/appmetadata/__init__.py b/clams/appmetadata/__init__.py index c619aeb..9157215 100644 --- a/clams/appmetadata/__init__.py +++ b/clams/appmetadata/__init__.py @@ -196,9 +196,8 @@ def __init__(self, **kwargs): self.multivalued = True if self.multivalued is None: self.multivalued = False - if self.multivalued: - if not isinstance(self.default, list): - self.default = [self.default] + if self.multivalued and self.default is not None and not isinstance(self.default, list): + self.default = [self.default] class Config: title = 'CLAMS App Runtime Parameter' @@ -404,7 +403,7 @@ def add_output(self, at_type: Union[str, vocabulary.ThingTypesBase], **propertie def add_parameter(self, name: str, description: str, type: param_value_types, choices: Optional[List[real_valued_primitives]] = None, multivalued: bool = False, - default: real_valued_primitives = None): + default: Union[real_valued_primitives, List[real_valued_primitives]] = None): """ Helper method to add an element to the ``parameters`` list. """ @@ -413,8 +412,17 @@ def add_parameter(self, name: str, description: str, type: param_value_types, # see https://docs.pydantic.dev/1.10/usage/types/#unions # e.g. casting 0.1 using the `primitives` dict will result in 0 (int) # while casting "0.1" using the `primitives` dict will result in 0.1 (float) - new_param = RuntimeParameter(name=name, description=description, type=type, - choices=choices, default=str(default) if default else default, multivalued=multivalued) + if type == 'map' and multivalued is False: + multivalued = True + if default is not None: + if isinstance(default, list): + default = [str(d) for d in default] + else: + default = str(default) + + new_param = RuntimeParameter( + name=name, description=description, type=type, choices=choices, multivalued=multivalued, + default=default) if new_param.name not in [param.name for param in self.parameters]: self.parameters.append(new_param) else: diff --git a/tests/test_clamsapp.py b/tests/test_clamsapp.py index 8928b2c..e565237 100644 --- a/tests/test_clamsapp.py +++ b/tests/test_clamsapp.py @@ -274,11 +274,22 @@ def test_open_document_location_custom_opener(self): def test_refine_parameters(self): self.app.metadata.parameters = [] - self.app.metadata.add_parameter('param1', 'first_param', 'string') - self.app.metadata.add_parameter('param2', 'second_param', 'string', default='second_default') - self.app.metadata.add_parameter('param3', 'third_param', 'boolean', default='f') - self.app.metadata.add_parameter('param4', 'fourth_param', 'integer', default='1', choices="1 2 3".split()) - self.app.metadata.add_parameter('param5', 'fifth_param', 'number', default='0.5') + self.app.metadata.add_parameter('param1', type='string', + description='string -def +req') + self.app.metadata.add_parameter('param2', type='string', default='second_default', + description='stirng +def') + self.app.metadata.add_parameter('param3', type='boolean', default='f', + description='bool +def') + self.app.metadata.add_parameter('param4', type='integer', default=1, choices="1 2 3".split(), + description='int +def +choices') + self.app.metadata.add_parameter('param5', type='number', default=0.5, + description='float +def') + self.app.metadata.add_parameter('param6', type='number', multivalued=True, default=[0.5], + description='float +def +mv') + self.app.metadata.add_parameter('param7', type='number', default=[], multivalued=True, + description='float +def +empty +mv') + with self.assertRaises(ValueError): + self.app._refine_params(**{}) # param1 is required, but not passed dummy_params = {'param1': ['okay'], 'non_parameter': ['should be ignored']} conf = self.app._refine_params(**dummy_params) # should not refine what's already refined @@ -286,17 +297,20 @@ def test_refine_parameters(self): self.assertTrue(clams.ClamsApp._RAW_PARAMS_KEY in double_refine) self.assertEqual(double_refine, conf) conf.pop(clams.ClamsApp._RAW_PARAMS_KEY, None) - self.assertEqual(len(conf), 5) # 1 from `param1`, 4 from default value + self.assertEqual(len(conf), 7) # 1 from `param1`, 6 from default value self.assertFalse('non_parameter' in conf) self.assertEqual(type(conf['param1']), str) self.assertEqual(type(conf['param2']), str) self.assertEqual(type(conf['param3']), bool) self.assertEqual(type(conf['param4']), int) self.assertEqual(type(conf['param5']), float) + self.assertEqual(type(conf['param6']), list) + self.assertEqual(type(conf['param7']), list) + self.assertEqual(len(conf['param7']), 0) with self.assertRaises(ValueError): # because param1 doesn't have a default value and thus a required param self.app._refine_params(param2=['okay']) - with self.assertRaisesRegexp(ValueError, r'.+must be one of.+'): + with self.assertRaisesRegex(ValueError, r'.+must be one of.+'): # because param4 can't be 4, note that param1 is "required" self.app._refine_params(param1=['p1'], param4=['4']) From 3462b541857e59454489b5cb7c8aa63a7aaa44a2 Mon Sep 17 00:00:00 2001 From: Keigh Rim Date: Sun, 31 Mar 2024 17:36:08 -0400 Subject: [PATCH 3/3] minor fix in autogeneration of app version --- clams/appmetadata/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/clams/appmetadata/__init__.py b/clams/appmetadata/__init__.py index 9157215..a1fe3ff 100644 --- a/clams/appmetadata/__init__.py +++ b/clams/appmetadata/__init__.py @@ -38,9 +38,12 @@ def generate_app_version(cwd=None): gitcmd = shutil.which('git') gitdir = (Path(sys.modules["__main__"].__file__).parent.resolve() if cwd is None else Path(cwd)) / '.git' if gitcmd is not None and gitdir.exists(): - proc = subprocess.run([gitcmd, '--git-dir', str(gitdir), 'describe', '--tags', '--always'], - capture_output=True, check=True) - return proc.stdout.decode('utf8').strip() + try: + proc = subprocess.run([gitcmd, '--git-dir', str(gitdir), 'describe', '--tags', '--always'], + capture_output=True, check=True) + return proc.stdout.decode('utf8').strip() + except subprocess.CalledProcessError: + return unresolved_app_version_num elif app_version_envvar_key in os.environ: return os.environ[app_version_envvar_key] else: