From 2abb943d3210e3be269801e4c8f3928e02192d7f Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Fri, 22 Mar 2024 21:47:39 +0000 Subject: [PATCH] 1600 allow providing default value in config (#1604) --- docs/source/configuration.rst | 20 ++++++++++++++++++ pygeoapi/util.py | 40 ++++++++++++++++++++++++----------- tests/test_util.py | 36 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index b9422d8..b11cfdd 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -412,6 +412,26 @@ Below is an example of how to integrate system environment variables in pygeoapi host: ${MY_HOST} port: ${MY_PORT} +Multiple environment variables are supported as follows: + +.. code-block:: yaml + + data: ${MY_HOST}:${MY_PORT} + +It is also possible to define a default value for a variable in case it does not exist in +the environment using a syntax like: ``value: ${ENV_VAR:-the default}`` + +.. code-block:: yaml + + server: + bind: + host: ${MY_HOST:-localhost} + port: ${MY_PORT:-5000} + metadata: + identification: + title: + en: This is pygeoapi host ${MY_HOST} and port ${MY_PORT:-5000}, nice to meet you! + Hierarchical collections ------------------------ diff --git a/pygeoapi/util.py b/pygeoapi/util.py index 280362d..2ccbeb2 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -163,23 +163,39 @@ def yaml_load(fh: IO) -> dict: :returns: `dict` representation of YAML """ - # support environment variables in config - # https://stackoverflow.com/a/55301129 - path_matcher = re.compile(r'.*\$\{([^}^{]+)\}.*') + # # support environment variables in config + # # https://stackoverflow.com/a/55301129 - def path_constructor(loader, node): - env_var = path_matcher.match(node.value).group(1) - if env_var not in os.environ: - msg = f'Undefined environment variable {env_var} in config' - raise EnvironmentError(msg) - return get_typed_value(os.path.expandvars(node.value)) + env_matcher = re.compile( + r'.*?\$\{(?P\w+)(:-(?P[^}]+))?\}') + + def env_constructor(loader, node): + result = "" + current_index = 0 + raw_value = node.value + for match_obj in env_matcher.finditer(raw_value): + groups = match_obj.groupdict() + varname_start = match_obj.span('varname')[0] + result += raw_value[current_index:(varname_start-2)] + if (var_value := os.getenv(groups['varname'])) is not None: + result += var_value + elif (default_value := groups.get('default')) is not None: + result += default_value + else: + raise EnvironmentError( + f'Could not find the {groups["varname"]!r} environment ' + f'variable' + ) + current_index = match_obj.end() + else: + result += raw_value[current_index:] + return get_typed_value(result) class EnvVarLoader(yaml.SafeLoader): pass - EnvVarLoader.add_implicit_resolver('!path', path_matcher, None) - EnvVarLoader.add_constructor('!path', path_constructor) - + EnvVarLoader.add_implicit_resolver('!env', env_matcher, None) + EnvVarLoader.add_constructor('!env', env_constructor) return yaml.load(fh, Loader=EnvVarLoader) diff --git a/tests/test_util.py b/tests/test_util.py index 98097f5..262cfcd 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -31,6 +31,8 @@ from datetime import datetime, date, time from decimal import Decimal from contextlib import nullcontext as does_not_raise from copy import deepcopy +from io import StringIO +from unittest import mock import pytest from pyproj.exceptions import CRSError @@ -77,6 +79,40 @@ def test_yaml_load(config): util.yaml_load(fh) +@pytest.mark.parametrize('env,input_config,expected', [ + pytest.param({}, 'foo: something', {'foo': 'something'}, id='no-env-expansion'), # noqa E501 + pytest.param({'FOO': 'this'}, 'foo: ${FOO}', {'foo': 'this'}), # noqa E501 + pytest.param({'FOO': 'this'}, 'foo: the value is ${FOO}', {'foo': 'the value is this'}, id='no-need-for-yaml-tag'), # noqa E501 + pytest.param({}, 'foo: ${FOO:-some default}', {'foo': 'some default'}), # noqa E501 + pytest.param({'FOO': 'this', 'BAR': 'that'}, 'composite: ${FOO}:${BAR}', {'composite': 'this:that'}), # noqa E501 + pytest.param({}, 'composite: ${FOO:-default-foo}:${BAR:-default-bar}', {'composite': 'default-foo:default-bar'}), # noqa E501 + pytest.param( + { + 'HOST': 'fake-host', + 'USER': 'fake', + 'PASSWORD': 'fake-pass', + 'DB': 'fake-db' + }, + 'connection: postgres://${USER}:${PASSWORD}@${HOST}:${PORT:-5432}/${DB}', # noqa E501 + { + 'connection': 'postgres://fake:fake-pass@fake-host:5432/fake-db' + }, + id='multiple-no-need-yaml-tag' + ), +]) +def test_yaml_load_with_env_variables( + env: dict[str, str], input_config: str, expected): + + def mock_get_env(env_var_name): + result = env.get(env_var_name) + return result + + with mock.patch('pygeoapi.util.os') as mock_os: + mock_os.getenv.side_effect = mock_get_env + loaded_config = util.yaml_load(StringIO(input_config)) + assert loaded_config == expected + + def test_str2bool(): assert not util.str2bool(False) assert not util.str2bool('0')