Required config options and strict mode (#360)

* Add strict mode + tests

* Add .required() for configuration option

* Add wiring tests for required() modifier

* Add wiring support

* Add tests for defined None values in required/strict mode

* Add docs

* Update changelog

* Update example doc block
This commit is contained in:
Roman Mogylatov 2021-01-16 08:53:40 -05:00 committed by GitHub
parent 3b69ed91c6
commit d74e8248a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 5958 additions and 5234 deletions

View File

@ -7,6 +7,12 @@ 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 ``strict`` mode and ``required`` modifier for ``Configuration`` provider.
See issue `#341 <https://github.com/ets-labs/python-dependency-injector/issues/341>`_.
Thanks `ms-lolo <https://github.com/ms-lolo>`_ for the feature request.
4.9.1 4.9.1
----- -----
- Fix a bug in the ``Configuration`` provider to correctly handle undefined values. - Fix a bug in the ``Configuration`` provider to correctly handle undefined values.

View File

@ -143,6 +143,28 @@ With the ``.as_(callback, *args, **kwargs)`` you can specify a function that wil
before the injection. The value from the config will be passed as a first argument. The returned before the injection. The value from the config will be passed as a first argument. The returned
value will be injected. Parameters ``*args`` and ``**kwargs`` are handled as any other injections. value will be injected. Parameters ``*args`` and ``**kwargs`` are handled as any other injections.
Strict mode and required options
--------------------------------
You can use configuration provider in strict mode. In strict mode configuration provider raises an error
on access to any undefined option.
.. literalinclude:: ../../examples/providers/configuration/configuration_strict.py
:language: python
:lines: 3-
:emphasize-lines: 12
You can also use ``.required()`` option modifier when making an injection.
.. literalinclude:: ../../examples/providers/configuration/configuration_required.py
:language: python
:lines: 11-20
:emphasize-lines: 8-9
.. note::
Modifier ``.required()`` should be specified before type modifier ``.as_*()``.
Injecting invariants Injecting invariants
-------------------- --------------------

View File

@ -0,0 +1,30 @@
"""`Configuration` provider required modifier example."""
from dependency_injector import containers, providers, errors
class ApiClient:
def __init__(self, api_key: str, timeout: int):
self.api_key = api_key
self.timeout = timeout
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
api_client_factory = providers.Factory(
ApiClient,
api_key=config.api.key.required(),
timeout=config.api.timeout.required().as_int(),
)
if __name__ == '__main__':
container = Container()
try:
api_client = container.api_client_factory()
except errors.Error:
# raises error: Undefined configuration option "config.api.key"
...

View File

@ -0,0 +1,30 @@
"""`Configuration` provider strict mode example."""
from dependency_injector import containers, providers, errors
class ApiClient:
def __init__(self, api_key: str, timeout: int):
self.api_key = api_key
self.timeout = timeout
class Container(containers.DeclarativeContainer):
config = providers.Configuration(strict=True)
api_client_factory = providers.Factory(
ApiClient,
api_key=config.api.key,
timeout=config.api.timeout.as_int(),
)
if __name__ == '__main__':
container = Container()
try:
api_client = container.api_client_factory()
except errors.Error:
# raises error: Undefined configuration option "config.api.key"
...

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -98,6 +98,7 @@ cdef class ConfigurationOption(Provider):
cdef tuple __name cdef tuple __name
cdef object __root_ref cdef object __root_ref
cdef dict __children cdef dict __children
cdef bint __required
cdef object __cache cdef object __cache
@ -107,6 +108,7 @@ cdef class TypedConfigurationOption(Callable):
cdef class Configuration(Object): cdef class Configuration(Object):
cdef str __name cdef str __name
cdef bint __strict
cdef dict __children cdef dict __children
cdef object __weakref__ cdef object __weakref__

View File

@ -146,6 +146,8 @@ class ConfigurationOption(Provider[Any]):
def as_int(self) -> TypedConfigurationOption[int]: ... def as_int(self) -> TypedConfigurationOption[int]: ...
def as_float(self) -> TypedConfigurationOption[float]: ... def as_float(self) -> TypedConfigurationOption[float]: ...
def as_(self, callback: _Callable[..., T], *args: Injection, **kwargs: Injection) -> TypedConfigurationOption[T]: ... def as_(self, callback: _Callable[..., T], *args: Injection, **kwargs: Injection) -> TypedConfigurationOption[T]: ...
def required(self) -> ConfigurationOption: ...
def is_required(self) -> bool: ...
def update(self, value: Any) -> None: ... def update(self, value: Any) -> None: ...
def from_ini(self, filepath: Union[Path, str]) -> None: ... def from_ini(self, filepath: Union[Path, str]) -> None: ...
def from_yaml(self, filepath: Union[Path, str]) -> None: ... def from_yaml(self, filepath: Union[Path, str]) -> None: ...
@ -160,7 +162,7 @@ class TypedConfigurationOption(Callable[T]):
class Configuration(Object[Any]): class Configuration(Object[Any]):
DEFAULT_NAME: str = 'config' DEFAULT_NAME: str = 'config'
def __init__(self, name: str = DEFAULT_NAME, default: Optional[Any] = None) -> None: ... def __init__(self, name: str = DEFAULT_NAME, default: Optional[Any] = None, *, strict: bool = False) -> None: ...
def __getattr__(self, item: str) -> ConfigurationOption: ... def __getattr__(self, item: str) -> ConfigurationOption: ...
def __getitem__(self, item: Union[str, Provider]) -> ConfigurationOption: ... def __getitem__(self, item: Union[str, Provider]) -> ConfigurationOption: ...
def get_name(self) -> str: ... def get_name(self) -> str: ...

View File

@ -1172,10 +1172,11 @@ cdef class ConfigurationOption(Provider):
UNDEFINED = object() UNDEFINED = object()
def __init__(self, name, root): def __init__(self, name, root, required=False):
self.__name = name self.__name = name
self.__root_ref = weakref.ref(root) self.__root_ref = weakref.ref(root)
self.__children = {} self.__children = {}
self.__required = required
self.__cache = self.UNDEFINED self.__cache = self.UNDEFINED
super().__init__() super().__init__()
@ -1193,7 +1194,7 @@ cdef class ConfigurationOption(Provider):
if copied_root is None: if copied_root is None:
copied_root = deepcopy(root, memo) copied_root = deepcopy(root, memo)
copied = self.__class__(copied_name, copied_root) copied = self.__class__(copied_name, copied_root, self.__required)
copied.__children = deepcopy(self.__children, memo) copied.__children = deepcopy(self.__children, memo)
return copied return copied
@ -1229,7 +1230,7 @@ cdef class ConfigurationOption(Provider):
return self.__cache return self.__cache
root = self.__root_ref() root = self.__root_ref()
value = root.get(self._get_self_name()) value = root.get(self._get_self_name(), self.__required)
self.__cache = value self.__cache = value
return value return value
@ -1258,6 +1259,12 @@ cdef class ConfigurationOption(Provider):
def as_(self, callback, *args, **kwargs): def as_(self, callback, *args, **kwargs):
return TypedConfigurationOption(callback, self, *args, **kwargs) return TypedConfigurationOption(callback, self, *args, **kwargs)
def required(self):
return self.__class__(self.__name, self.__root_ref(), required=True)
def is_required(self):
return self.__required
def override(self, value): def override(self, value):
if isinstance(value, Provider): if isinstance(value, Provider):
raise Error('Configuration option can only be overridden by a value') raise Error('Configuration option can only be overridden by a value')
@ -1396,9 +1403,11 @@ cdef class Configuration(Object):
""" """
DEFAULT_NAME = 'config' DEFAULT_NAME = 'config'
UNDEFINED = object()
def __init__(self, name=DEFAULT_NAME, default=None): def __init__(self, name=DEFAULT_NAME, default=None, strict=False):
self.__name = name self.__name = name
self.__strict = strict
value = {} value = {}
if default is not None: if default is not None:
@ -1416,7 +1425,7 @@ cdef class Configuration(Object):
if copied is not None: if copied is not None:
return copied return copied
copied = self.__class__(self.__name, self.__provides) copied = self.__class__(self.__name, self.__provides, self.__strict)
memo[id(self)] = copied memo[id(self)] = copied
copied.__children = deepcopy(self.__children, memo) copied.__children = deepcopy(self.__children, memo)
@ -1450,12 +1459,15 @@ cdef class Configuration(Object):
def get_name(self): def get_name(self):
return self.__name return self.__name
def get(self, selector): def get(self, selector, required=False):
"""Return configuration option. """Return configuration option.
:param selector: Selector string, e.g. "option1.option2" :param selector: Selector string, e.g. "option1.option2"
:type selector: str :type selector: str
:param required: Required flag, raise error if required option is missing
:type required: bool
:return: Option value. :return: Option value.
:rtype: Any :rtype: Any
""" """
@ -1467,9 +1479,12 @@ cdef class Configuration(Object):
while len(keys) > 0: while len(keys) > 0:
key = keys.pop(0) key = keys.pop(0)
value = value.get(key) value = value.get(key, self.UNDEFINED)
if value is None:
break if value is self.UNDEFINED:
if self.__strict or required:
raise Error('Undefined configuration option "{0}.{1}"'.format(self.__name, selector))
return None
return value return value

View File

@ -157,6 +157,9 @@ class ProvidersMap:
else: else:
new = getattr(new, segment) new = getattr(new, segment)
if original.is_required():
new = new.required()
if as_: if as_:
new = new.as_(as_) new = new.as_(as_)

View File

@ -21,3 +21,7 @@ config3 = providers.Configuration()
int3: providers.Callable[int] = config3.option.as_int() int3: providers.Callable[int] = config3.option.as_int()
float3: providers.Callable[float] = config3.option.as_float() float3: providers.Callable[float] = config3.option.as_float()
int3_custom: providers.Callable[int] = config3.option.as_(int) int3_custom: providers.Callable[int] = config3.option.as_(int)
# Test 4: to check required() method
config4 = providers.Configuration()
option4: providers.ConfigurationOption = config4.option.required()

View File

@ -97,6 +97,39 @@ class ConfigTests(unittest.TestCase):
self.assertEqual(value, decimal.Decimal('123.123')) self.assertEqual(value, decimal.Decimal('123.123'))
def test_required(self):
provider = providers.Callable(
lambda value: value,
self.config.a.required(),
)
with self.assertRaisesRegex(errors.Error, 'Undefined configuration option "config.a"'):
provider()
def test_required_defined_none(self):
provider = providers.Callable(
lambda value: value,
self.config.a.required(),
)
self.config.from_dict({'a': None})
self.assertIsNone(provider())
def test_required_no_side_effect(self):
_ = providers.Callable(
lambda value: value,
self.config.a.required(),
)
self.assertIsNone(self.config.a())
def test_required_as_(self):
provider = providers.List(
self.config.int_test.required().as_int(),
self.config.float_test.required().as_float(),
self.config._as_test.required().as_(decimal.Decimal),
)
self.config.from_dict({'int_test': '1', 'float_test': '2.0', '_as_test': '3.0'})
self.assertEqual(provider(), [1, 2.0, decimal.Decimal('3.0')])
def test_providers_value_override(self): def test_providers_value_override(self):
a = self.config.a a = self.config.a
ab = self.config.a.b ab = self.config.a.b
@ -176,6 +209,16 @@ class ConfigTests(unittest.TestCase):
def test_value_of_undefined_option(self): def test_value_of_undefined_option(self):
self.assertIsNone(self.config.a()) self.assertIsNone(self.config.a())
def test_value_of_undefined_option_in_strict_mode(self):
self.config = providers.Configuration(strict=True)
with self.assertRaisesRegex(errors.Error, 'Undefined configuration option "config.a"'):
self.config.a()
def test_value_of_defined_none_option_in_strict_mode(self):
self.config = providers.Configuration(strict=True)
self.config.from_dict({'a': None})
self.assertIsNone(self.config.a())
def test_getting_of_special_attributes(self): def test_getting_of_special_attributes(self):
with self.assertRaises(AttributeError): with self.assertRaises(AttributeError):
self.config.__name__ self.config.__name__

View File

@ -43,11 +43,30 @@ def test_function_provider(service_provider: Callable[..., Service] = Provider[C
@inject @inject
def test_config_value( def test_config_value(
some_value_int: int = Provide[Container.config.a.b.c.as_int()], value_int: int = Provide[Container.config.a.b.c.as_int()],
some_value_str: str = Provide[Container.config.a.b.c.as_(str)], value_str: str = Provide[Container.config.a.b.c.as_(str)],
some_value_decimal: Decimal = Provide[Container.config.a.b.c.as_(Decimal)], value_decimal: Decimal = Provide[Container.config.a.b.c.as_(Decimal)],
value_required: str = Provide[Container.config.a.b.c.required()],
value_required_int: int = Provide[Container.config.a.b.c.required().as_int()],
value_required_str: str = Provide[Container.config.a.b.c.required().as_(str)],
value_required_decimal: str = Provide[Container.config.a.b.c.required().as_(Decimal)],
): ):
return some_value_int, some_value_str, some_value_decimal return (
value_int,
value_str,
value_decimal,
value_required,
value_required_int,
value_required_str,
value_required_decimal,
)
@inject
def test_config_value_required_undefined(
value_required: int = Provide[Container.config.a.b.c.required()],
):
return value_required
@inject @inject

View File

@ -2,6 +2,7 @@ from decimal import Decimal
import unittest import unittest
from dependency_injector.wiring import wire, Provide, Closing from dependency_injector.wiring import wire, Provide, Closing
from dependency_injector import errors
# Runtime import to avoid syntax errors in samples on Python < 3.5 # Runtime import to avoid syntax errors in samples on Python < 3.5
import os import os
@ -109,10 +110,28 @@ class WiringTest(unittest.TestCase):
self.assertIs(service, test_service) self.assertIs(service, test_service)
def test_configuration_option(self): def test_configuration_option(self):
int_value, str_value, decimal_value = module.test_config_value() (
self.assertEqual(int_value, 10) value_int,
self.assertEqual(str_value, '10') value_str,
self.assertEqual(decimal_value, Decimal(10)) value_decimal,
value_required,
value_required_int,
value_required_str,
value_required_decimal,
) = module.test_config_value()
self.assertEqual(value_int, 10)
self.assertEqual(value_str, '10')
self.assertEqual(value_decimal, Decimal(10))
self.assertEqual(value_required, 10)
self.assertEqual(value_required_int, 10)
self.assertEqual(value_required_str, '10')
self.assertEqual(value_required_decimal, Decimal(10))
def test_configuration_option_required_undefined(self):
self.container.config.reset_override()
with self.assertRaisesRegex(errors.Error, 'Undefined configuration option "config.a.b.c"'):
module.test_config_value_required_undefined()
def test_provide_provider(self): def test_provide_provider(self):
service = module.test_provide_provider() service = module.test_provide_provider()