459 Add default value for environment variable for yaml and ini config files (#461)

* Add tests for partial yaml interpolation

* Add tests for partial ini interpolation

* Add yaml config env defaults parsing

* Implement default interpolation for ini files

* Add tests for ini files env interpolation

* Update docs

* Update docs

* Update config docs keywords
This commit is contained in:
Roman Mogylatov 2021-06-13 15:07:30 -04:00 committed by GitHub
parent 585c717650
commit bbd623c719
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 9002 additions and 8369 deletions

View File

@ -7,6 +7,13 @@ that were made in every particular version.
From version 0.7.6 *Dependency Injector* framework strictly From version 0.7.6 *Dependency Injector* framework strictly
follows `Semantic versioning`_ follows `Semantic versioning`_
Development version
------
- Add support of default value for environment variable in INI and YAML
configuration files with ``${ENV_NAME:default}`` format.
See issue `#459 <https://github.com/ets-labs/python-dependency-injector/issues/459>`_.
Thanks to `Maksym Shemet @hbmshemet <https://github.com/hbmshemet>`_ for suggesting the feature.
4.32.3 4.32.3
------ ------
- This fix a typo in ``di_in_python.rst`` doc. - This fix a typo in ``di_in_python.rst`` doc.

View File

@ -5,7 +5,7 @@ Configuration provider
.. meta:: .. meta::
:keywords: Python,DI,Dependency injection,IoC,Inversion of Control,Configuration,Injection, :keywords: Python,DI,Dependency injection,IoC,Inversion of Control,Configuration,Injection,
Option,Ini,Json,Yaml,Pydantic,Dict,Environment Variable,Load,Read,Get Option,Ini,Json,Yaml,Pydantic,Dict,Environment Variable,Default,Load,Read,Get
:description: Configuration provides configuration options to the other providers. This page :description: Configuration provides configuration options to the other providers. This page
demonstrates how to use Configuration provider to inject the dependencies, load demonstrates how to use Configuration provider to inject the dependencies, load
a configuration from an ini or yaml file, a dictionary, an environment variable, a configuration from an ini or yaml file, a dictionary, an environment variable,
@ -43,8 +43,18 @@ where ``examples/providers/configuration/config.ini`` is:
:language: ini :language: ini
:py:meth:`Configuration.from_ini` method supports environment variables interpolation. Use :py:meth:`Configuration.from_ini` method supports environment variables interpolation. Use
``${ENV_NAME}`` format in the configuration file to substitute value of the environment ``${ENV_NAME}`` format in the configuration file to substitute value from ``ENV_NAME`` environment
variable ``ENV_NAME``. variable.
You can also specify a default value using ``${ENV_NAME:default}`` format. If environment
variable ``ENV_NAME`` is undefined, configuration provider will substitute value ``default``.
.. code-block:: ini
[section]
option1 = {$ENV_VAR}
option2 = {$ENV_VAR}/path
option3 = {$ENV_VAR:default}
Loading from a YAML file Loading from a YAML file
------------------------ ------------------------
@ -62,12 +72,22 @@ where ``examples/providers/configuration/config.yml`` is:
.. literalinclude:: ../../examples/providers/configuration/config.yml .. literalinclude:: ../../examples/providers/configuration/config.yml
:language: ini :language: ini
:py:meth:`Configuration.from_yaml` method supports environment variables interpolation. Use
``${ENV_NAME}`` format in the configuration file to substitute value from ``ENV_NAME`` environment
variable.
You can also specify a default value using ``${ENV_NAME:default}`` format. If environment
variable ``ENV_NAME`` is undefined, configuration provider will substitute value ``default``.
.. code-block:: ini
section:
option1: {$ENV_VAR}
option2: {$ENV_VAR}/path
option3: {$ENV_VAR:default}
:py:meth:`Configuration.from_yaml` method uses custom version of ``yaml.SafeLoader``. :py:meth:`Configuration.from_yaml` method uses custom version of ``yaml.SafeLoader``.
To use another loader use ``loader`` argument:
The loader supports environment variables interpolation. Use ``${ENV_NAME}`` format
in the configuration file to substitute value of the environment variable ``ENV_NAME``.
You can also specify a YAML loader as an argument:
.. code-block:: python .. code-block:: python

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,25 @@ else: # pragma: no cover
copy.deepcopy(obj.im_self, memo), copy.deepcopy(obj.im_self, memo),
obj.im_class) obj.im_class)
config_env_marker_pattern = re.compile(
r'\${(?P<name>[^}^{:]+)(?P<separator>:?)(?P<default>.*?)}',
)
def _resolve_config_env_markers(config_value):
""""Replace environment variable markers with their values."""
for match in reversed(list(config_env_marker_pattern.finditer(config_value))):
has_default = match.group('separator') == ':'
value = os.getenv(match.group('name'))
if value is None:
if not has_default:
continue
value = match.group('default')
span_min, span_max = match.span()
config_value = f'{config_value[:span_min]}{value}{config_value[span_max:]}'
return config_value
if sys.version_info[0] == 3: if sys.version_info[0] == 3:
class EnvInterpolation(iniconfigparser.BasicInterpolation): class EnvInterpolation(iniconfigparser.BasicInterpolation):
@ -71,7 +90,7 @@ if sys.version_info[0] == 3:
def before_get(self, parser, section, option, value, defaults): def before_get(self, parser, section, option, value, defaults):
value = super().before_get(parser, section, option, value, defaults) value = super().before_get(parser, section, option, value, defaults)
return os.path.expandvars(value) return _resolve_config_env_markers(value)
def _parse_ini_file(filepath): def _parse_ini_file(filepath):
parser = iniconfigparser.ConfigParser(interpolation=EnvInterpolation()) parser = iniconfigparser.ConfigParser(interpolation=EnvInterpolation())
@ -84,19 +103,18 @@ else:
def _parse_ini_file(filepath): def _parse_ini_file(filepath):
parser = iniconfigparser.ConfigParser() parser = iniconfigparser.ConfigParser()
with open(filepath) as config_file: with open(filepath) as config_file:
config_string = os.path.expandvars(config_file.read()) config_string = _resolve_config_env_markers(config_file.read())
parser.readfp(StringIO.StringIO(config_string)) parser.readfp(StringIO.StringIO(config_string))
return parser return parser
if yaml: if yaml:
# TODO: use SafeLoader without env interpolation by default in version 5.* # TODO: use SafeLoader without env interpolation by default in version 5.*
yaml_env_marker_pattern = re.compile(r'\$\{([^}^{]+)\}')
def yaml_env_marker_constructor(_, node): def yaml_env_marker_constructor(_, node):
""""Replace environment variable marker with its value.""" """"Replace environment variable marker with its value."""
return os.path.expandvars(node.value) return _resolve_config_env_markers(node.value)
yaml.add_implicit_resolver('!path', yaml_env_marker_pattern) yaml.add_implicit_resolver('!path', config_env_marker_pattern)
yaml.add_constructor('!path', yaml_env_marker_constructor) yaml.add_constructor('!path', yaml_env_marker_constructor)
class YamlLoader(yaml.SafeLoader): class YamlLoader(yaml.SafeLoader):
@ -105,7 +123,7 @@ if yaml:
Inherits ``yaml.SafeLoader`` and add environment variables interpolation. Inherits ``yaml.SafeLoader`` and add environment variables interpolation.
""" """
YamlLoader.add_implicit_resolver('!path', yaml_env_marker_pattern, None) YamlLoader.add_implicit_resolver('!path', config_env_marker_pattern, None)
YamlLoader.add_constructor('!path', yaml_env_marker_constructor) YamlLoader.add_constructor('!path', yaml_env_marker_constructor)
else: else:
class YamlLoader: class YamlLoader:

View File

@ -591,17 +591,20 @@ class ConfigFromIniWithEnvInterpolationTests(unittest.TestCase):
self.config = providers.Configuration(name='config') self.config = providers.Configuration(name='config')
os.environ['CONFIG_TEST_ENV'] = 'test-value' os.environ['CONFIG_TEST_ENV'] = 'test-value'
os.environ['CONFIG_TEST_PATH'] = 'test-path'
_, self.config_file = tempfile.mkstemp() _, self.config_file = tempfile.mkstemp()
with open(self.config_file, 'w') as config_file: with open(self.config_file, 'w') as config_file:
config_file.write( config_file.write(
'[section1]\n' '[section1]\n'
'value1=${CONFIG_TEST_ENV}\n' 'value1=${CONFIG_TEST_ENV}\n'
'value2=${CONFIG_TEST_PATH}/path\n'
) )
def tearDown(self): def tearDown(self):
del self.config del self.config
del os.environ['CONFIG_TEST_ENV'] os.environ.pop('CONFIG_TEST_ENV', None)
os.environ.pop('CONFIG_TEST_PATH', None)
os.unlink(self.config_file) os.unlink(self.config_file)
def test_env_variable_interpolation(self): def test_env_variable_interpolation(self):
@ -612,11 +615,67 @@ class ConfigFromIniWithEnvInterpolationTests(unittest.TestCase):
{ {
'section1': { 'section1': {
'value1': 'test-value', 'value1': 'test-value',
'value2': 'test-path/path',
}, },
}, },
) )
self.assertEqual(self.config.section1(), {'value1': 'test-value'}) self.assertEqual(
self.config.section1(),
{
'value1': 'test-value',
'value2': 'test-path/path',
},
)
self.assertEqual(self.config.section1.value1(), 'test-value') self.assertEqual(self.config.section1.value1(), 'test-value')
self.assertEqual(self.config.section1.value2(), 'test-path/path')
def test_missing_envs(self):
del os.environ['CONFIG_TEST_ENV']
del os.environ['CONFIG_TEST_PATH']
self.config.from_ini(self.config_file)
self.assertEqual(
self.config(),
{
'section1': {
'value1': '${CONFIG_TEST_ENV}',
'value2': '${CONFIG_TEST_PATH}/path',
},
},
)
self.assertEqual(
self.config.section1(),
{
'value1': '${CONFIG_TEST_ENV}',
'value2': '${CONFIG_TEST_PATH}/path',
},
)
self.assertEqual(self.config.section1.value1(), '${CONFIG_TEST_ENV}')
self.assertEqual(self.config.section1.value2(), '${CONFIG_TEST_PATH}/path')
def test_default_values(self):
os.environ['DEFINED'] = 'defined'
self.addCleanup(os.environ.pop, 'DEFINED')
with open(self.config_file, 'w') as config_file:
config_file.write(
'[section]\n'
'defined_with_default=${DEFINED:default}\n'
'undefined_with_default=${UNDEFINED:default}\n'
'complex=${DEFINED}/path/${DEFINED:default}/${UNDEFINED}/${UNDEFINED:default}\n'
)
self.config.from_ini(self.config_file)
self.assertEqual(
self.config.section(),
{
'defined_with_default': 'defined',
'undefined_with_default': 'default',
'complex': 'defined/path/defined/${UNDEFINED}/default',
},
)
class ConfigFromYamlTests(unittest.TestCase): class ConfigFromYamlTests(unittest.TestCase):
@ -781,17 +840,20 @@ class ConfigFromYamlWithEnvInterpolationTests(unittest.TestCase):
self.config = providers.Configuration(name='config') self.config = providers.Configuration(name='config')
os.environ['CONFIG_TEST_ENV'] = 'test-value' os.environ['CONFIG_TEST_ENV'] = 'test-value'
os.environ['CONFIG_TEST_PATH'] = 'test-path'
_, self.config_file = tempfile.mkstemp() _, self.config_file = tempfile.mkstemp()
with open(self.config_file, 'w') as config_file: with open(self.config_file, 'w') as config_file:
config_file.write( config_file.write(
'section1:\n' 'section1:\n'
' value1: ${CONFIG_TEST_ENV}\n' ' value1: ${CONFIG_TEST_ENV}\n'
' value2: ${CONFIG_TEST_PATH}/path\n'
) )
def tearDown(self): def tearDown(self):
del self.config del self.config
del os.environ['CONFIG_TEST_ENV'] os.environ.pop('CONFIG_TEST_ENV', None)
os.environ.pop('CONFIG_TEST_PATH', None)
os.unlink(self.config_file) os.unlink(self.config_file)
@unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4') @unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4')
@ -803,11 +865,69 @@ class ConfigFromYamlWithEnvInterpolationTests(unittest.TestCase):
{ {
'section1': { 'section1': {
'value1': 'test-value', 'value1': 'test-value',
'value2': 'test-path/path',
}, },
}, },
) )
self.assertEqual(self.config.section1(), {'value1': 'test-value'}) self.assertEqual(
self.config.section1(),
{
'value1': 'test-value',
'value2': 'test-path/path',
},
)
self.assertEqual(self.config.section1.value1(), 'test-value') self.assertEqual(self.config.section1.value1(), 'test-value')
self.assertEqual(self.config.section1.value2(), 'test-path/path')
@unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4')
def test_missing_envs(self):
del os.environ['CONFIG_TEST_ENV']
del os.environ['CONFIG_TEST_PATH']
self.config.from_yaml(self.config_file)
self.assertEqual(
self.config(),
{
'section1': {
'value1': '${CONFIG_TEST_ENV}',
'value2': '${CONFIG_TEST_PATH}/path',
},
},
)
self.assertEqual(
self.config.section1(),
{
'value1': '${CONFIG_TEST_ENV}',
'value2': '${CONFIG_TEST_PATH}/path',
},
)
self.assertEqual(self.config.section1.value1(), '${CONFIG_TEST_ENV}')
self.assertEqual(self.config.section1.value2(), '${CONFIG_TEST_PATH}/path')
@unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4')
def test_default_values(self):
os.environ['DEFINED'] = 'defined'
self.addCleanup(os.environ.pop, 'DEFINED')
with open(self.config_file, 'w') as config_file:
config_file.write(
'section:\n'
' defined_with_default: ${DEFINED:default}\n'
' undefined_with_default: ${UNDEFINED:default}\n'
' complex: ${DEFINED}/path/${DEFINED:default}/${UNDEFINED}/${UNDEFINED:default}\n'
)
self.config.from_yaml(self.config_file)
self.assertEqual(
self.config.section(),
{
'defined_with_default': 'defined',
'undefined_with_default': 'default',
'complex': 'defined/path/defined/${UNDEFINED}/default',
},
)
@unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4') @unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4')
def test_option_env_variable_interpolation(self): def test_option_env_variable_interpolation(self):
@ -818,41 +938,47 @@ class ConfigFromYamlWithEnvInterpolationTests(unittest.TestCase):
{ {
'section1': { 'section1': {
'value1': 'test-value', 'value1': 'test-value',
'value2': 'test-path/path',
}, },
}, },
) )
self.assertEqual(self.config.option.section1(), {'value1': 'test-value'}) self.assertEqual(
self.config.option.section1(),
{
'value1': 'test-value',
'value2': 'test-path/path',
},
)
self.assertEqual(self.config.option.section1.value1(), 'test-value') self.assertEqual(self.config.option.section1.value1(), 'test-value')
self.assertEqual(self.config.option.section1.value2(), 'test-path/path')
@unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4') @unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4')
def test_env_variable_interpolation_custom_loader(self): def test_env_variable_interpolation_custom_loader(self):
self.config.from_yaml(self.config_file, loader=yaml.UnsafeLoader) self.config.from_yaml(self.config_file, loader=yaml.UnsafeLoader)
self.assertEqual( self.assertEqual(
self.config(), self.config.section1(),
{ {
'section1': {
'value1': 'test-value', 'value1': 'test-value',
}, 'value2': 'test-path/path',
}, },
) )
self.assertEqual(self.config.section1(), {'value1': 'test-value'})
self.assertEqual(self.config.section1.value1(), 'test-value') self.assertEqual(self.config.section1.value1(), 'test-value')
self.assertEqual(self.config.section1.value2(), 'test-path/path')
@unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4') @unittest.skipIf(sys.version_info[:2] == (3, 4), 'PyYAML does not support Python 3.4')
def test_option_env_variable_interpolation_custom_loader(self): def test_option_env_variable_interpolation_custom_loader(self):
self.config.option.from_yaml(self.config_file, loader=yaml.UnsafeLoader) self.config.option.from_yaml(self.config_file, loader=yaml.UnsafeLoader)
self.assertEqual( self.assertEqual(
self.config.option(), self.config.option.section1(),
{ {
'section1': {
'value1': 'test-value', 'value1': 'test-value',
}, 'value2': 'test-path/path',
}, },
) )
self.assertEqual(self.config.option.section1(), {'value1': 'test-value'})
self.assertEqual(self.config.option.section1.value1(), 'test-value') self.assertEqual(self.config.option.section1.value1(), 'test-value')
self.assertEqual(self.config.option.section1.value2(), 'test-path/path')
class ConfigFromPydanticTests(unittest.TestCase): class ConfigFromPydanticTests(unittest.TestCase):