diff --git a/samples/basic_params/functions/main.py b/samples/basic_params/functions/main.py index b39686b..731e77b 100644 --- a/samples/basic_params/functions/main.py +++ b/samples/basic_params/functions/main.py @@ -28,6 +28,18 @@ default="/images/processed", ) +image_type = params.ListParam( + "IMAGE_TYPE", + label="convert image to preferred types", + description="The image types you'd like your source image to convert to.", + input=params.MultiSelectInput([ + params.SelectOption(value="jpeg", label="jpeg"), + params.SelectOption(value="png", label="png"), + params.SelectOption(value="webp", label="webp"), + ]), + default=["jpeg", "png"], +) + delete_original = params.BoolParam( "DELETE_ORIGINAL_FILE", label="delete the original file", @@ -56,6 +68,7 @@ def resize_images(event: storage_fn.CloudEvent[storage_fn.StorageObjectData]): This function will be triggered when a new object is created in the bucket. """ print("Got a new image:", event) + print("Selected image types:", image_type.value) print("Selected bucket resource:", bucket.value) print("Selected output location:", output_path.value) print("Testing a not so secret api key:", image_resize_api_secret.value) diff --git a/src/firebase_functions/params.py b/src/firebase_functions/params.py index dd97f6a..fbf869d 100644 --- a/src/firebase_functions/params.py +++ b/src/firebase_functions/params.py @@ -14,6 +14,7 @@ """Module for params that can make Cloud Functions codebases generic.""" import abc as _abc +import json as _json import dataclasses as _dataclasses import os as _os import re as _re @@ -139,6 +140,18 @@ class SelectInput(_typing.Generic[_T]): """A list of user selectable options.""" +@_dataclasses.dataclass(frozen=True) +class MultiSelectInput(): + """ + Specifies that a Param's value should be determined by having the user select + a subset from a list of pre-canned options interactively at deploy-time. + Will result in errors if used on Params of type other than string[]. + """ + + options: list[SelectOption[str]] + """A list of user selectable options.""" + + @_dataclasses.dataclass(frozen=True) class TextInput: """ @@ -215,7 +228,8 @@ class Param(Expression[_T]): deployments. """ - input: TextInput | ResourceInput | SelectInput[_T] | None = None + input: TextInput | ResourceInput | SelectInput[ + _T] | MultiSelectInput | None = None """ The type of input that is required for this param, e.g. TextInput. """ @@ -355,6 +369,32 @@ def value(self) -> bool: return False +@_dataclasses.dataclass(frozen=True) +class ListParam(Param[list]): + """A parameter as a list of strings.""" + + @property + def value(self) -> list[str]: + if _os.environ.get(self.name) is not None: + # If the environment variable starts with "[" and ends with "]", + # then assume it is a JSON array and try to parse it. + # (This is for Cloud Run (v2 Functions), the environment variable is a JSON array.) + if _os.environ[self.name].startswith("[") and _os.environ[ + self.name].endswith("]"): + try: + return _json.loads(_os.environ[self.name]) + except _json.JSONDecodeError: + return [] + # Otherwise, split the string by commas. + # (This is for emulator & the Firebase CLI generated .env file, the environment + # variable is a comma-separated list.) + return list(filter(len, _os.environ[self.name].split(","))) + if self.default is not None: + return self.default.value if isinstance( + self.default, Expression) else self.default + return [] + + @_dataclasses.dataclass(frozen=True) class _DefaultStringParam(StringParam): """ diff --git a/src/firebase_functions/private/manifest.py b/src/firebase_functions/private/manifest.py index 794b9f1..15227d5 100644 --- a/src/firebase_functions/private/manifest.py +++ b/src/firebase_functions/private/manifest.py @@ -181,7 +181,8 @@ class ManifestStack: def _param_input_to_spec( - param_input: _params.TextInput | _params.ResourceInput | _params.SelectInput + param_input: _params.TextInput | _params.ResourceInput | + _params.SelectInput | _params.MultiSelectInput ) -> dict[str, _typing.Any]: if isinstance(param_input, _params.TextInput): return { @@ -204,9 +205,11 @@ def _param_input_to_spec( }, } - if isinstance(param_input, _params.SelectInput): + if isinstance(param_input, (_params.MultiSelectInput, _params.SelectInput)): + key = "select" if isinstance(param_input, + _params.SelectInput) else "multiSelect" return { - "select": { + key: { "options": [{ key: value for key, value in { "value": option.value, @@ -242,6 +245,8 @@ def _param_to_spec( spec_dict["type"] = "float" elif isinstance(param, _params.SecretParam): spec_dict["type"] = "secret" + elif isinstance(param, _params.ListParam): + spec_dict["type"] = "list" elif isinstance(param, _params.StringParam): spec_dict["type"] = "string" else: diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 317e175..dc0db9c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -68,43 +68,41 @@ _params.FloatParam("FLOAT_TEST", immutable=True), _params.SecretParam("SECRET_TEST"), _params.StringParam("STRING_TEST"), + _params.ListParam("LIST_TEST", default=["1", "2", "3"]), ], requiredAPIs=[{ "api": "test_api", "reason": "testing" - }], -) + }]) full_stack_dict = { "specVersion": "v1alpha1", "endpoints": { "test": full_endpoint_dict }, - "params": [ - { - "name": "BOOL_TEST", - "type": "boolean", - "default": False, - }, - { - "name": "INT_TEST", - "type": "int", - "description": "int_description" - }, - { - "name": "FLOAT_TEST", - "type": "float", - "immutable": True, - }, - { - "name": "SECRET_TEST", - "type": "secret" - }, - { - "name": "STRING_TEST", - "type": "string" - }, - ], + "params": [{ + "name": "BOOL_TEST", + "type": "boolean", + "default": False, + }, { + "name": "INT_TEST", + "type": "int", + "description": "int_description" + }, { + "name": "FLOAT_TEST", + "type": "float", + "immutable": True, + }, { + "name": "SECRET_TEST", + "type": "secret" + }, { + "name": "STRING_TEST", + "type": "string" + }, { + "default": ["1", "2", "3"], + "name": "LIST_TEST", + "type": "list" + }], "requiredAPIs": [{ "api": "test_api", "reason": "testing" diff --git a/tests/test_params.py b/tests/test_params.py index f98499c..b367bf6 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -146,6 +146,42 @@ def test_string_param_equality(self): is False), "Failure, equality check returned False" +class TestListParams: + """ListParam unit tests.""" + + def test_list_param_value(self): + """Testing if list param correctly returns list values.""" + environ["LIST_VALUE_TEST1"] = "item1,item2" + assert params.ListParam("LIST_VALUE_TEST1").value == ["item1","item2"], \ + 'Failure, params value != ["item1","item2"]' + + def test_list_param_filter_empty_strings(self): + """Testing if list param correctly returns list values wth empty strings excluded.""" + environ["LIST_VALUE_TEST2"] = ",,item1,item2,,,item3," + assert params.ListParam("LIST_VALUE_TEST2").value == ["item1","item2", "item3"], \ + 'Failure, params value != ["item1","item2", "item3"]' + + def test_list_param_empty_default(self): + """Testing if list param defaults to an empty list if no value and no default.""" + assert params.ListParam("LIST_DEFAULT_TEST1").value == [], \ + "Failure, params value is not an empty list" + + def test_list_param_default(self): + """Testing if list param defaults to the provided default value.""" + assert (params.ListParam("LIST_DEFAULT_TEST2", default=["1", "2"]).value + == ["1", "2"]), \ + 'Failure, params default value != ["1", "2"]' + + def test_list_param_equality(self): + """Test list equality.""" + assert (params.ListParam("LIST_TEST1", + default=["123"]).equals(["123"]).value + is True), "Failure, equality check returned False" + assert (params.ListParam("LIST_TEST2", + default=["456"]).equals(["123"]).value + is False), "Failure, equality check returned False" + + class TestParamsManifest: """ Tests any created params are tracked for the purposes