Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions guide/content/en/guide/running/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,57 @@ If a value cannot be cast, it will default to a `str`.
app = Sanic(..., config=Config(converters=[UUID]))
```

#### Advanced Type Converters

.. column::

For more sophisticated conversion logic that needs access to the full environment variable context, you can use `DetailedConverter`. This abstract base class provides access to the full environment variable key, the raw value, and the current config defaults.

This is useful when you need to:
- Cast values to the type of their defaults
- Perform validation based on the variable name pattern
- Use default values for fallback logic
- Access configuration context during conversion

.. column::

```python
from sanic.config import DetailedConverter

class DefaultsTypeCastingConverter(DetailedConverter):
def __call__(self, full_key: str, config_key: str, value: str, defaults: dict):
try:
if config_key in defaults:
return type(defaults[config_key])(value)
except (ValueError, TypeError) as e:
raise TypeError(f"Configuration environment variable '{full_key}' type mismatch: expected"
f" {type(defaults[config_key]).__name__}, got {type(value).__name__}") from e

app = Sanic(..., config=Config(converters=[DefaultsTypeCastingConverter()]))
```

.. column::

The `DetailedConverter.__call__` method receives four parameters:

- `full_key`: The full environment variable name with prefix (e.g., "SANIC_DATABASE_URL")
- `config_key`: The config key without prefix (e.g., "DATABASE_URL")
- `value`: The raw string value from the environment
- `defaults`: The current default configuration values

.. column::

```python
class ValidationConverter(DetailedConverter):
def __call__(self, full_key: str, config_key: str, value: str, defaults: dict):
if config_key.endswith('_PORT'):
port = int(value)
if not 1 <= port <= 65535:
raise ValueError(f"Invalid port: {port}")
return port
raise ValueError # Let other converters handle it
```

## Builtin values

| **Variable** | **Default** | **Description** |
Expand Down
72 changes: 68 additions & 4 deletions sanic/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from abc import ABCMeta
from abc import ABC, ABCMeta, abstractmethod
from collections.abc import Sequence
from inspect import getmembers, isclass, isdatadescriptor
from os import environ
Expand Down Expand Up @@ -84,6 +84,50 @@ def _is_setter(member: object):
return isdatadescriptor(member) and hasattr(member, "setter")


class DetailedConverter(ABC):
"""Base class for detailed converters that need additional context.

DetailedConverter provides access to the full environment variable key,
the raw value, and the current config defaults. This allows for more
sophisticated conversion logic that can take into account the variable
name pattern, perform validation, or use default values for fallback.

Examples:
```python
# Example of a converter that casts values to the type of the default
class DefaultsCastConverter(DetailedConverter):
def __call__(self, full_key: str, config_key: str, value: str,
defaults: dict) -> Any:
try:
if config_key in defaults:
return type(defaults[config_key])(value)
except (ValueError, TypeError):
raise ValueError
```
"""

@abstractmethod
def __call__(
self, full_key: str, config_key: str, value: str, defaults: dict
) -> Any:
"""Convert an environment variable to a Python value.

Args:
full_key: The full environment variable name (with prefix)
(e.g., "SANIC_DATABASE_URL")
config_key: The environment variable name (without prefix)
(e.g., "DATABASE_URL")
value: The raw string value from the environment
defaults: The current default configuration values

Returns:
The converted Python value

Raises:
ValueError: If the value cannot be converted by this converter
"""


class Config(dict, metaclass=DescriptorMeta):
"""Configuration object for Sanic.

Expand Down Expand Up @@ -144,7 +188,8 @@ def __init__(
converters: Optional[Sequence[Callable[[str], Any]]] = None,
):
defaults = defaults or {}
super().__init__({**DEFAULT_CONFIG, **defaults})
self.defaults = {**DEFAULT_CONFIG, **defaults}
super().__init__(self.defaults)
self._configure_warnings()

self._converters = [str, str_to_bool, float, int]
Expand Down Expand Up @@ -327,7 +372,12 @@ def load_environment_vars(self, prefix=SANIC_PREFIX):

for converter in reversed(self._converters):
try:
self[config_key] = converter(value)
if isinstance(converter, DetailedConverter):
self[config_key] = converter(
key, config_key, value, self.defaults
)
else:
self[config_key] = converter(value)
break
except ValueError:
pass
Expand Down Expand Up @@ -413,7 +463,7 @@ def register_type(self, converter: Callable[[str], Any]) -> None:

Args:
converter (Callable[[str], Any]): A function that takes a string
and returns a value of any type.
and returns a value of any type, or a DetailedConverter instance.

Examples:
```python
Expand All @@ -422,6 +472,20 @@ def my_converter(value: str) -> Any:
return value

config.register_type(my_converter)

# Or use a DetailedConverter for more context
# Example of a converter that casts values to
# the type of the default
class DefaultsCastConverter(DetailedConverter):
def __call__(self, full_key: str, config_key: str, value: str,
defaults: dict) -> Any:
try:
if config_key in defaults:
return type(defaults[config_key])(value)
except (ValueError, TypeError):
raise ValueError

config.register_type(DefaultsCastConverter())
```
"""
if converter in self._converters:
Expand Down
67 changes: 66 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pytest import MonkeyPatch

from sanic import Sanic
from sanic.config import DEFAULT_CONFIG, Config
from sanic.config import DEFAULT_CONFIG, Config, DetailedConverter
from sanic.constants import LocalCertCreator
from sanic.exceptions import PyFileError

Expand Down Expand Up @@ -148,6 +148,71 @@ def converter(): ...
assert len(config._converters) == 5


class MockDetailedConverter(DetailedConverter):
"""Mock converter that returns a dict with all passed parameters."""

def __call__(
self, full_key: str, config_key: str, value: str, defaults: dict
):
return {
"full_key": full_key,
"config_key": config_key,
"value": value,
"has_default": config_key in defaults,
"default_value": defaults.get(config_key, None),
}


def test_detailed_converter_basic_functionality():
"""Test that DetailedConverter receives all expected parameters."""
environ["SANIC_TEST_DETAILED"] = "test_value"

config = Config(converters=[MockDetailedConverter()])

result = config.TEST_DETAILED
assert isinstance(result, dict)
assert result["full_key"] == "SANIC_TEST_DETAILED"
assert result["config_key"] == "TEST_DETAILED"
assert result["value"] == "test_value"
assert result["has_default"] is False
assert result["default_value"] is None

del environ["SANIC_TEST_DETAILED"]


def test_detailed_converter_with_defaults():
"""Test DetailedConverter with custom defaults."""
environ["SANIC_CUSTOM_VALUE"] = "42"

defaults = {"CUSTOM_VALUE": 100}
config = Config(defaults=defaults, converters=[MockDetailedConverter()])

result = config.CUSTOM_VALUE
assert isinstance(result, dict)
assert result["full_key"] == "SANIC_CUSTOM_VALUE"
assert result["config_key"] == "CUSTOM_VALUE"
assert result["value"] == "42"
assert result["has_default"] is True
assert result["default_value"] == 100

del environ["SANIC_CUSTOM_VALUE"]


def test_detailed_converter_with_custom_prefix():
"""Test DetailedConverter with custom environment prefix."""
environ["MYAPP_TEST_VALUE"] = "custom_test"

config = Config(env_prefix="MYAPP_", converters=[MockDetailedConverter()])

result = config.TEST_VALUE
assert isinstance(result, dict)
assert result["full_key"] == "MYAPP_TEST_VALUE"
assert result["config_key"] == "TEST_VALUE"
assert result["value"] == "custom_test"

del environ["MYAPP_TEST_VALUE"]


def test_load_from_file(app: Sanic):
config = dedent(
"""
Expand Down
Loading