Env custom type casting (#2330)

This commit is contained in:
Adam Hopkins 2021-12-21 00:50:45 +02:00 committed by GitHub
parent d799c5f03c
commit 080d41627a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 77 additions and 3 deletions

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from inspect import getmembers, isclass, isdatadescriptor from inspect import getmembers, isclass, isdatadescriptor
from os import environ from os import environ
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Union from typing import Any, Callable, Dict, Optional, Sequence, Union
from warnings import warn from warnings import warn
from sanic.errorpages import DEFAULT_FORMAT, check_error_format from sanic.errorpages import DEFAULT_FORMAT, check_error_format
@ -43,6 +43,10 @@ DEFAULT_CONFIG = {
"WEBSOCKET_PING_TIMEOUT": 20, "WEBSOCKET_PING_TIMEOUT": 20,
} }
# These values will be removed from the Config object in v22.6 and moved
# to the application state
DEPRECATED_CONFIG = ("SERVER_RUNNING", "RELOADER_PROCESS", "RELOADED_FILES")
class DescriptorMeta(type): class DescriptorMeta(type):
def __init__(cls, *_): def __init__(cls, *_):
@ -85,12 +89,19 @@ class Config(dict, metaclass=DescriptorMeta):
load_env: Optional[Union[bool, str]] = True, load_env: Optional[Union[bool, str]] = True,
env_prefix: Optional[str] = SANIC_PREFIX, env_prefix: Optional[str] = SANIC_PREFIX,
keep_alive: Optional[bool] = None, keep_alive: Optional[bool] = None,
*,
converters: Optional[Sequence[Callable[[str], Any]]] = None,
): ):
defaults = defaults or {} defaults = defaults or {}
super().__init__({**DEFAULT_CONFIG, **defaults}) super().__init__({**DEFAULT_CONFIG, **defaults})
self._converters = [str, str_to_bool, float, int]
self._LOGO = "" self._LOGO = ""
if converters:
for converter in converters:
self.register_type(converter)
if keep_alive is not None: if keep_alive is not None:
self.KEEP_ALIVE = keep_alive self.KEEP_ALIVE = keep_alive
@ -199,7 +210,23 @@ class Config(dict, metaclass=DescriptorMeta):
- ``float`` - ``float``
- ``bool`` - ``bool``
Anything else will be imported as a ``str``. Anything else will be imported as a ``str``. If you would like to add
additional types to this list, you can use
:meth:`sanic.config.Config.register_type`. Just make sure that they
are registered before you instantiate your application.
.. code-block:: python
class Foo:
def __init__(self, name) -> None:
self.name = name
config = Config(converters=[Foo])
app = Sanic(__name__, config=config)
`See user guide re: config
<https://sanicframework.org/guide/deployment/configuration.html>`__
""" """
for key, value in environ.items(): for key, value in environ.items():
if not key.startswith(prefix): if not key.startswith(prefix):
@ -207,7 +234,7 @@ class Config(dict, metaclass=DescriptorMeta):
_, config_key = key.split(prefix, 1) _, config_key = key.split(prefix, 1)
for converter in (int, float, str_to_bool, str): for converter in reversed(self._converters):
try: try:
self[config_key] = converter(value) self[config_key] = converter(value)
break break
@ -282,3 +309,17 @@ class Config(dict, metaclass=DescriptorMeta):
self.update(config) self.update(config)
load = update_config load = update_config
def register_type(self, converter: Callable[[str], Any]) -> None:
"""
Allows for adding custom function to cast from a string value to any
other type. The function should raise ValueError if it is not the
correct type.
"""
if converter in self._converters:
error_logger.warning(
f"Configuration value converter '{converter.__name__}' has "
"already been registered"
)
return
self._converters.append(converter)

View File

@ -1,3 +1,5 @@
import logging
from contextlib import contextmanager from contextlib import contextmanager
from os import environ from os import environ
from pathlib import Path from pathlib import Path
@ -32,6 +34,11 @@ class ConfigTest:
return self.not_for_config return self.not_for_config
class UltimateAnswer:
def __init__(self, answer):
self.answer = int(answer)
def test_load_from_object(app): def test_load_from_object(app):
app.config.load(ConfigTest) app.config.load(ConfigTest)
assert "CONFIG_VALUE" in app.config assert "CONFIG_VALUE" in app.config
@ -137,6 +144,32 @@ def test_env_prefix_string_value():
del environ["MYAPP_TEST_TOKEN"] del environ["MYAPP_TEST_TOKEN"]
def test_env_w_custom_converter():
environ["SANIC_TEST_ANSWER"] = "42"
config = Config(converters=[UltimateAnswer])
app = Sanic(name=__name__, config=config)
assert isinstance(app.config.TEST_ANSWER, UltimateAnswer)
assert app.config.TEST_ANSWER.answer == 42
del environ["SANIC_TEST_ANSWER"]
def test_add_converter_multiple_times(caplog):
def converter():
...
message = (
"Configuration value converter 'converter' has already been registered"
)
config = Config()
config.register_type(converter)
with caplog.at_level(logging.WARNING):
config.register_type(converter)
assert ("sanic.error", logging.WARNING, message) in caplog.record_tuples
assert len(config._converters) == 5
def test_load_from_file(app): def test_load_from_file(app):
config = dedent( config = dedent(
""" """