Add config.from_env(as_=...) (#541)

* Add implementation and typing stub

* Add unit tests

* Update demo example

* Add typing tests

* Update changelog

* Update docs

* Add tests for an empty environment variable

* Improve wording in di_in_python.rst

* Update wording in changelog and docs

* Update doc blocks
This commit is contained in:
Roman Mogylatov 2021-12-20 23:46:51 +01:00 committed by GitHub
parent cc17052acc
commit cfadd8c3fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 5514 additions and 5287 deletions

View File

@ -90,7 +90,7 @@ Key features of the ``Dependency Injector``:
api_client = providers.Singleton( api_client = providers.Singleton(
ApiClient, ApiClient,
api_key=config.api_key, api_key=config.api_key,
timeout=config.timeout.as_int(), timeout=config.timeout,
) )
service = providers.Factory( service = providers.Factory(
@ -106,8 +106,8 @@ Key features of the ``Dependency Injector``:
if __name__ == "__main__": if __name__ == "__main__":
container = Container() container = Container()
container.config.api_key.from_env("API_KEY") container.config.api_key.from_env("API_KEY", required=True)
container.config.timeout.from_env("TIMEOUT") container.config.timeout.from_env("TIMEOUT", as_=int, default=5)
container.wire(modules=[__name__]) container.wire(modules=[__name__])
main() # <-- dependency is injected automatically main() # <-- dependency is injected automatically

View File

@ -96,7 +96,7 @@ Key features of the ``Dependency Injector``:
api_client = providers.Singleton( api_client = providers.Singleton(
ApiClient, ApiClient,
api_key=config.api_key, api_key=config.api_key,
timeout=config.timeout.as_int(), timeout=config.timeout,
) )
service = providers.Factory( service = providers.Factory(
@ -112,8 +112,8 @@ Key features of the ``Dependency Injector``:
if __name__ == "__main__": if __name__ == "__main__":
container = Container() container = Container()
container.config.api_key.from_env("API_KEY") container.config.api_key.from_env("API_KEY", required=True)
container.config.timeout.from_env("TIMEOUT") container.config.timeout.from_env("TIMEOUT", as_=int, default=5)
container.wire(modules=[__name__]) container.wire(modules=[__name__])
main() # <-- dependency is injected automatically main() # <-- dependency is injected automatically

View File

@ -150,7 +150,7 @@ What does the Dependency Injector do?
------------------------------------- -------------------------------------
With the dependency injection pattern objects loose the responsibility of assembling With the dependency injection pattern objects loose the responsibility of assembling
the dependencies. The ``Dependency Injector`` absorbs that responsibilities. the dependencies. The ``Dependency Injector`` absorbs that responsibility.
``Dependency Injector`` helps to assemble and inject the dependencies. ``Dependency Injector`` helps to assemble and inject the dependencies.
@ -172,7 +172,7 @@ the dependency.
api_client = providers.Singleton( api_client = providers.Singleton(
ApiClient, ApiClient,
api_key=config.api_key, api_key=config.api_key,
timeout=config.timeout.as_int(), timeout=config.timeout,
) )
service = providers.Factory( service = providers.Factory(
@ -188,8 +188,8 @@ the dependency.
if __name__ == "__main__": if __name__ == "__main__":
container = Container() container = Container()
container.config.api_key.from_env("API_KEY") container.config.api_key.from_env("API_KEY", required=True)
container.config.timeout.from_env("TIMEOUT") container.config.timeout.from_env("TIMEOUT", as_=int, default=5)
container.wire(modules=[__name__]) container.wire(modules=[__name__])
main() # <-- dependency is injected automatically main() # <-- dependency is injected automatically

View File

@ -9,6 +9,8 @@ follows `Semantic versioning`_
Development version Development version
------------------- -------------------
- Add argument ``as_`` to the ``config.from_env()`` method for the explicit type casting
of an environment variable value, e.g.: ``config.timeout.from_env("TIMEOUT", as_=int)``.
- Add ``.providers`` attribute to the ``FactoryAggregate`` provider. It is an alias for - Add ``.providers`` attribute to the ``FactoryAggregate`` provider. It is an alias for
``FactoryAggregate.factories`` attribute. ``FactoryAggregate.factories`` attribute.
- Add ``.set_providers()`` method to the ``FactoryAggregate`` provider. It is an alias for - Add ``.set_providers()`` method to the ``FactoryAggregate`` provider. It is an alias for

View File

@ -205,6 +205,24 @@ Loading from an environment variable
:lines: 3- :lines: 3-
:emphasize-lines: 18-20 :emphasize-lines: 18-20
You can use ``as_`` argument for the type casting of an environment variable value:
.. code-block:: python
:emphasize-lines: 2,6,10
# API_KEY=secret
container.config.api_key.from_env("API_KEY", as_=str, required=True)
assert container.config.api_key() == "secret"
# SAMPLING_RATIO=0.5
container.config.sampling.from_env("SAMPLING_RATIO", as_=float, required=True)
assert container.config.sampling() == 0.5
# TIMEOUT undefined, default is used
container.config.timeout.from_env("TIMEOUT", as_=int, default=5)
assert container.config.timeout() == 5
Loading a value Loading a value
--------------- ---------------

View File

@ -13,7 +13,7 @@ class Container(containers.DeclarativeContainer):
api_client = providers.Singleton( api_client = providers.Singleton(
ApiClient, ApiClient,
api_key=config.api_key, api_key=config.api_key,
timeout=config.timeout.as_int(), timeout=config.timeout,
) )
service = providers.Factory( service = providers.Factory(
@ -29,8 +29,8 @@ def main(service: Service = Provide[Container.service]):
if __name__ == "__main__": if __name__ == "__main__":
container = Container() container = Container()
container.config.api_key.from_env("API_KEY") container.config.api_key.from_env("API_KEY", required=True)
container.config.timeout.from_env("TIMEOUT") container.config.timeout.from_env("TIMEOUT", as_=int, default=5)
container.wire(modules=[__name__]) container.wire(modules=[__name__])
main() # <-- dependency is injected automatically main() # <-- dependency is injected automatically

File diff suppressed because it is too large Load Diff

View File

@ -205,7 +205,7 @@ class ConfigurationOption(Provider[Any]):
def from_yaml(self, filepath: Union[Path, str], required: bool = False, loader: Optional[Any] = None, envs_required: bool = False) -> None: ... def from_yaml(self, filepath: Union[Path, str], required: bool = False, loader: Optional[Any] = None, envs_required: bool = False) -> None: ...
def from_pydantic(self, settings: PydanticSettings, required: bool = False, **kwargs: Any) -> None: ... def from_pydantic(self, settings: PydanticSettings, required: bool = False, **kwargs: Any) -> None: ...
def from_dict(self, options: _Dict[str, Any], required: bool = False) -> None: ... def from_dict(self, options: _Dict[str, Any], required: bool = False) -> None: ...
def from_env(self, name: str, default: Optional[Any] = None, required: bool = False) -> None: ... def from_env(self, name: str, default: Optional[Any] = None, required: bool = False, as_: Optional[_Callable[..., Any]] = None) -> None: ...
def from_value(self, value: Any) -> None: ... def from_value(self, value: Any) -> None: ...
@ -262,7 +262,7 @@ class Configuration(Object[Any]):
def from_yaml(self, filepath: Union[Path, str], required: bool = False, loader: Optional[Any] = None, envs_required: bool = False) -> None: ... def from_yaml(self, filepath: Union[Path, str], required: bool = False, loader: Optional[Any] = None, envs_required: bool = False) -> None: ...
def from_pydantic(self, settings: PydanticSettings, required: bool = False, **kwargs: Any) -> None: ... def from_pydantic(self, settings: PydanticSettings, required: bool = False, **kwargs: Any) -> None: ...
def from_dict(self, options: _Dict[str, Any], required: bool = False) -> None: ... def from_dict(self, options: _Dict[str, Any], required: bool = False) -> None: ...
def from_env(self, name: str, default: Optional[Any] = None, required: bool = False) -> None: ... def from_env(self, name: str, default: Optional[Any] = None, required: bool = False, as_: Optional[_Callable[..., Any]] = None) -> None: ...
def from_value(self, value: Any) -> None: ... def from_value(self, value: Any) -> None: ...

View File

@ -1689,7 +1689,7 @@ cdef class ConfigurationOption(Provider):
self.override(merge_dicts(current_config, options)) self.override(merge_dicts(current_config, options))
def from_env(self, name, default=UNDEFINED, required=UNDEFINED): def from_env(self, name, default=UNDEFINED, required=UNDEFINED, as_=UNDEFINED):
"""Load configuration value from the environment variable. """Load configuration value from the environment variable.
:param name: Name of the environment variable. :param name: Name of the environment variable.
@ -1701,6 +1701,9 @@ cdef class ConfigurationOption(Provider):
:param required: When required is True, raise an exception if environment variable is undefined. :param required: When required is True, raise an exception if environment variable is undefined.
:type required: bool :type required: bool
:param as_: Callable used for type casting (int, float, etc).
:type as_: object
:rtype: None :rtype: None
""" """
value = os.environ.get(name, default) value = os.environ.get(name, default)
@ -1711,6 +1714,9 @@ cdef class ConfigurationOption(Provider):
raise ValueError('Environment variable "{0}" is undefined'.format(name)) raise ValueError('Environment variable "{0}" is undefined'.format(name))
value = None value = None
if as_ is not UNDEFINED:
value = as_(value)
self.override(value) self.override(value)
def from_value(self, value): def from_value(self, value):
@ -2191,7 +2197,7 @@ cdef class Configuration(Object):
current_config = {} current_config = {}
self.override(merge_dicts(current_config, options)) self.override(merge_dicts(current_config, options))
def from_env(self, name, default=UNDEFINED, required=UNDEFINED): def from_env(self, name, default=UNDEFINED, required=UNDEFINED, as_=UNDEFINED):
"""Load configuration value from the environment variable. """Load configuration value from the environment variable.
:param name: Name of the environment variable. :param name: Name of the environment variable.
@ -2203,6 +2209,9 @@ cdef class Configuration(Object):
:param required: When required is True, raise an exception if environment variable is undefined. :param required: When required is True, raise an exception if environment variable is undefined.
:type required: bool :type required: bool
:param as_: Callable used for type casting (int, float, etc).
:type as_: object
:rtype: None :rtype: None
""" """
value = os.environ.get(name, default) value = os.environ.get(name, default)
@ -2213,6 +2222,9 @@ cdef class Configuration(Object):
raise ValueError('Environment variable "{0}" is undefined'.format(name)) raise ValueError('Environment variable "{0}" is undefined'.format(name))
value = None value = None
if as_ is not UNDEFINED:
value = as_(value)
self.override(value) self.override(value)
def from_value(self, value): def from_value(self, value):

View File

@ -14,7 +14,11 @@ config2.from_ini(Path("config.ini"))
config2.from_yaml("config.yml") config2.from_yaml("config.yml")
config2.from_yaml(Path("config.yml")) config2.from_yaml(Path("config.yml"))
config2.from_env("ENV", "default") config2.from_env("ENV", "default")
config2.from_env("ENV", as_=int, default=123)
config2.from_env("ENV", as_=float, required=True)
config2.from_env("ENV", as_=lambda env: str(env))
# Test 3: to check as_*() methods # Test 3: to check as_*() methods
config3 = providers.Configuration() config3 = providers.Configuration()

View File

@ -106,7 +106,11 @@ def environment_variables():
os.environ["CONFIG_TEST_ENV"] = "test-value" os.environ["CONFIG_TEST_ENV"] = "test-value"
os.environ["CONFIG_TEST_PATH"] = "test-path" os.environ["CONFIG_TEST_PATH"] = "test-path"
os.environ["DEFINED"] = "defined" os.environ["DEFINED"] = "defined"
os.environ["EMPTY"] = ""
os.environ["CONFIG_INT"] = "42"
yield yield
os.environ.pop("CONFIG_TEST_ENV", None) os.environ.pop("CONFIG_TEST_ENV", None)
os.environ.pop("CONFIG_TEST_PATH", None) os.environ.pop("CONFIG_TEST_PATH", None)
os.environ.pop("DEFINED", None) os.environ.pop("DEFINED", None)
os.environ.pop("EMPTY", None)
os.environ.pop("CONFIG_INT", None)

View File

@ -31,6 +31,54 @@ def test_option_default_none(config):
assert config.option() is None assert config.option() is None
def test_as_(config):
config.from_env("CONFIG_INT", as_=int)
assert config() == 42
assert isinstance(config(), int)
def test_as__default(config):
config.from_env("UNDEFINED", as_=int, default="33")
assert config() == 33
assert isinstance(config(), int)
def test_as__undefined_required(config):
with raises(ValueError):
config.from_env("UNDEFINED", as_=int, required=True)
assert config() == {}
def test_as__defined_empty(config):
with raises(ValueError):
config.from_env("EMPTY", as_=int)
assert config() == {}
def test_option_as_(config):
config.option.from_env("CONFIG_INT", as_=int)
assert config.option() == 42
assert isinstance(config.option(), int)
def test_option_as__default(config):
config.option.from_env("UNDEFINED", as_=int, default="33")
assert config.option() == 33
assert isinstance(config.option(), int)
def test_option_as__undefined_required(config):
with raises(ValueError):
config.option.from_env("UNDEFINED", as_=int, required=True)
assert config.option() is None
def test_option_as__defined_empty(config):
with raises(ValueError):
config.option.from_env("EMPTY", as_=int)
assert config.option() is None
@mark.parametrize("config_type", ["strict"]) @mark.parametrize("config_type", ["strict"])
def test_undefined_in_strict_mode(config): def test_undefined_in_strict_mode(config):
with raises(ValueError): with raises(ValueError):