Skip to content

Commit 2abb943

Browse files
1600 allow providing default value in config (geopython#1604)
1 parent 421559a commit 2abb943

File tree

3 files changed

+85
-13
lines changed

3 files changed

+85
-13
lines changed

docs/source/configuration.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,26 @@ Below is an example of how to integrate system environment variables in pygeoapi
412412
host: ${MY_HOST}
413413
port: ${MY_PORT}
414414
415+
Multiple environment variables are supported as follows:
416+
417+
.. code-block:: yaml
418+
419+
data: ${MY_HOST}:${MY_PORT}
420+
421+
It is also possible to define a default value for a variable in case it does not exist in
422+
the environment using a syntax like: ``value: ${ENV_VAR:-the default}``
423+
424+
.. code-block:: yaml
425+
426+
server:
427+
bind:
428+
host: ${MY_HOST:-localhost}
429+
port: ${MY_PORT:-5000}
430+
metadata:
431+
identification:
432+
title:
433+
en: This is pygeoapi host ${MY_HOST} and port ${MY_PORT:-5000}, nice to meet you!
434+
415435
416436
Hierarchical collections
417437
------------------------

pygeoapi/util.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -163,23 +163,39 @@ def yaml_load(fh: IO) -> dict:
163163
:returns: `dict` representation of YAML
164164
"""
165165

166-
# support environment variables in config
167-
# https://stackoverflow.com/a/55301129
168-
path_matcher = re.compile(r'.*\$\{([^}^{]+)\}.*')
169-
170-
def path_constructor(loader, node):
171-
env_var = path_matcher.match(node.value).group(1)
172-
if env_var not in os.environ:
173-
msg = f'Undefined environment variable {env_var} in config'
174-
raise EnvironmentError(msg)
175-
return get_typed_value(os.path.expandvars(node.value))
166+
# # support environment variables in config
167+
# # https://stackoverflow.com/a/55301129
168+
169+
env_matcher = re.compile(
170+
r'.*?\$\{(?P<varname>\w+)(:-(?P<default>[^}]+))?\}')
171+
172+
def env_constructor(loader, node):
173+
result = ""
174+
current_index = 0
175+
raw_value = node.value
176+
for match_obj in env_matcher.finditer(raw_value):
177+
groups = match_obj.groupdict()
178+
varname_start = match_obj.span('varname')[0]
179+
result += raw_value[current_index:(varname_start-2)]
180+
if (var_value := os.getenv(groups['varname'])) is not None:
181+
result += var_value
182+
elif (default_value := groups.get('default')) is not None:
183+
result += default_value
184+
else:
185+
raise EnvironmentError(
186+
f'Could not find the {groups["varname"]!r} environment '
187+
f'variable'
188+
)
189+
current_index = match_obj.end()
190+
else:
191+
result += raw_value[current_index:]
192+
return get_typed_value(result)
176193

177194
class EnvVarLoader(yaml.SafeLoader):
178195
pass
179196

180-
EnvVarLoader.add_implicit_resolver('!path', path_matcher, None)
181-
EnvVarLoader.add_constructor('!path', path_constructor)
182-
197+
EnvVarLoader.add_implicit_resolver('!env', env_matcher, None)
198+
EnvVarLoader.add_constructor('!env', env_constructor)
183199
return yaml.load(fh, Loader=EnvVarLoader)
184200

185201

tests/test_util.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
from decimal import Decimal
3232
from contextlib import nullcontext as does_not_raise
3333
from copy import deepcopy
34+
from io import StringIO
35+
from unittest import mock
3436

3537
import pytest
3638
from pyproj.exceptions import CRSError
@@ -77,6 +79,40 @@ def test_yaml_load(config):
7779
util.yaml_load(fh)
7880

7981

82+
@pytest.mark.parametrize('env,input_config,expected', [
83+
pytest.param({}, 'foo: something', {'foo': 'something'}, id='no-env-expansion'), # noqa E501
84+
pytest.param({'FOO': 'this'}, 'foo: ${FOO}', {'foo': 'this'}), # noqa E501
85+
pytest.param({'FOO': 'this'}, 'foo: the value is ${FOO}', {'foo': 'the value is this'}, id='no-need-for-yaml-tag'), # noqa E501
86+
pytest.param({}, 'foo: ${FOO:-some default}', {'foo': 'some default'}), # noqa E501
87+
pytest.param({'FOO': 'this', 'BAR': 'that'}, 'composite: ${FOO}:${BAR}', {'composite': 'this:that'}), # noqa E501
88+
pytest.param({}, 'composite: ${FOO:-default-foo}:${BAR:-default-bar}', {'composite': 'default-foo:default-bar'}), # noqa E501
89+
pytest.param(
90+
{
91+
'HOST': 'fake-host',
92+
'USER': 'fake',
93+
'PASSWORD': 'fake-pass',
94+
'DB': 'fake-db'
95+
},
96+
'connection: postgres://${USER}:${PASSWORD}@${HOST}:${PORT:-5432}/${DB}', # noqa E501
97+
{
98+
'connection': 'postgres://fake:fake-pass@fake-host:5432/fake-db'
99+
},
100+
id='multiple-no-need-yaml-tag'
101+
),
102+
])
103+
def test_yaml_load_with_env_variables(
104+
env: dict[str, str], input_config: str, expected):
105+
106+
def mock_get_env(env_var_name):
107+
result = env.get(env_var_name)
108+
return result
109+
110+
with mock.patch('pygeoapi.util.os') as mock_os:
111+
mock_os.getenv.side_effect = mock_get_env
112+
loaded_config = util.yaml_load(StringIO(input_config))
113+
assert loaded_config == expected
114+
115+
80116
def test_str2bool():
81117
assert not util.str2bool(False)
82118
assert not util.str2bool('0')

0 commit comments

Comments
 (0)