Module: settings

Expand source code
# Copyright (C) 2023-present The Project Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations
import os
from abc import ABC
from abc import abstractmethod
from dataclasses import MISSING
from dataclasses import dataclass
from typing import ClassVar
from typing import Dict
from typing import Iterable
from typing import List
from typing import Type
from dotenv import find_dotenv
from dotenv import load_dotenv
from dynaconf import Dynaconf
from typing_extensions import Self
from cl.runtime.context.env_util import EnvUtil
from cl.runtime.primitive.timestamp import Timestamp
from cl.runtime.records.record_util import RecordUtil
from cl.runtime.settings.project_settings import SETTINGS_FILES_ENVVAR
from cl.runtime.settings.project_settings import ProjectSettings

# Load dotenv first (the priority order is envvars first, then dotenv, then settings.yaml and .secrets.yaml)
load_dotenv()

_process_timestamp = Timestamp.create()
"""Unique UUIDv7-based timestamp set during the Python process launch."""

# True if we are inside a test, the result is cached in Settings for performance
_is_inside_test = EnvUtil.is_inside_test()

# Select Dynaconf test environment when invoked from the pytest or UnitTest test runner.
# Other runners not detected automatically, in which case the Dynaconf environment must be
# configured in settings explicitly.
if _is_inside_test:
    os.environ["CL_SETTINGS_ENV"] = "test"

_all_settings = Dynaconf(
    environments=True,
    envvar_prefix="CL",
    env_switcher="CL_SETTINGS_ENV",
    envvar=SETTINGS_FILES_ENVVAR,
    settings_files=[
        # Specify the exact path to prevent uncertainty associated with searching in multiple directories
        os.path.normpath(os.path.join(ProjectSettings.get_project_root(), "settings.yaml")),
        os.path.normpath(os.path.join(ProjectSettings.get_project_root(), ".secrets.yaml")),
    ],
    dotenv_override=True,
)
"""
Dynaconf settings in raw format (including system settings), some keys may be strings instead of dictionaries or lists.
"""

_user_settings = {k.lower(): v for k, v in _all_settings.as_dict().items()}
"""
Extract user settings only using as_dict(), then convert containers at all levels to dictionaries and lists
and convert root level keys to lowercase in case the settings are specified using envvars in uppercase format
"""

_dynaconf_envvar_prefix = _all_settings.envvar_prefix_for_dynaconf
"""Environment variable prefix for overriding dynaconf file settings."""

_dynaconf_file_patterns = _all_settings.settings_file
"""List of Dynaconf settings file patterns or file paths."""

# Convert to list if a single string is specified
if isinstance(_dynaconf_file_patterns, str):
    _dynaconf_file_patterns = [_dynaconf_file_patterns]

_dynaconf_loaded_files = _all_settings._loaded_files  # noqa
"""Loaded dynaconf settings files."""

_dynaconf_dir_path = _all_settings._root_path  # noqa
"""Absolute path the location of the first Dynaconf file if found, None otherwise."""

_dotenv_file_path = find_dotenv_output if (find_dotenv_output := find_dotenv()) != "" else None
"""Absolute path to .env file if found, None otherwise."""

_dotenv_dir_path = os.path.dirname(_dotenv_file_path) if _dotenv_file_path is not None else None
"""Absolute path to .env directory if found, None otherwise."""


@dataclass(slots=True, kw_only=True)
class Settings(ABC):
    """Base class for a singleton settings object."""

    process_timestamp: ClassVar[str] = _process_timestamp
    """Unique UUIDv7-based timestamp set during the Python process launch."""

    is_inside_test: ClassVar[bool] = _is_inside_test
    """True if we are inside a test."""

    __settings_dict: ClassVar[Dict[Type, Settings]] = {}
    """Dictionary of initialized settings objects indexed by the the settings class type."""

    @classmethod
    @abstractmethod
    def get_prefix(cls) -> str:
        """
        Dynaconf fields will be filtered by 'prefix_' before being passed to the settings class constructor.

        Notes:
            - The prefix must be lowercase
            - The prefix must not start or end with underscore but may include underscore separator(s)
            - The prefix is removed before the fields are provided to the constructor of this settings class
        """

    @classmethod
    def instance(cls) -> Self:
        """Return singleton instance."""

        # Check if cached value exists, load if not found
        if (result := cls.__settings_dict.get(cls, None)) is None:
            # A settings class may specify an optional prefix used to filter dynaconf fields
            prefix = cls.get_prefix()

            # Validate prefix
            prefix_description = f"Dynaconf settings prefix '{prefix}' returned by '{cls.__name__}.get_prefix()'"
            if prefix is None:
                raise RuntimeError(f"{prefix_description} is None.")
            if prefix == "":
                raise RuntimeError(f"{prefix_description} is an empty string.")
            if not prefix.islower():
                raise RuntimeError(f"{prefix_description} must be lowercase.")
            if prefix.startswith("_"):
                raise RuntimeError(f"{prefix_description} must not start with an underscore.")
            if prefix.endswith("_"):
                raise RuntimeError(f"{prefix_description} must not end with an underscore.")

            # List of required fields in cls (fields for which neither default nor default_factory is specified)
            required_fields = [
                name
                for name, field_info in cls.__dataclass_fields__.items()  # noqa
                if field_info.default is MISSING and field_info.default_factory is MISSING
            ]

            # Filter user settings by 'prefix_' and create a new dictionary where prefix is removed from keys
            # This will include fields that are not specified in the settings class
            p = prefix + "_"
            settings_dict = {k[len(p) :]: v for k, v in _user_settings.items() if k.startswith(p)}

            # Check for missing required fields
            missing_fields = [k for k in required_fields if k not in settings_dict]
            if missing_fields:
                # Combine the global Dynaconf envvar prefix with settings prefix in uppercase
                envvar_prefix = f"{_dynaconf_envvar_prefix}_{prefix.upper()}"
                dynaconf_msg = f"(in lowercase with prefix '{prefix}_')"
                envvar_msg = f"(in uppercase with prefix '{envvar_prefix}_')"

                # Environment variables
                sources_list = [f"Environment variables {envvar_msg}"]

                # Dotenv file or message that it is not found
                if (env_file := find_dotenv()) != "":
                    env_file_name = env_file
                else:
                    env_file_name = "No .env file in default search path"
                sources_list.append(f"Dotenv file {envvar_msg}: {env_file_name}")

                # Dynaconf file(s) or message that they are not found
                if _dynaconf_loaded_files:
                    dynaconf_file_list = _dynaconf_loaded_files
                else:
                    _dynaconf_file_patterns_str = ", ".join(_dynaconf_file_patterns)
                    dynaconf_file_list = [f"No {_dynaconf_file_patterns_str} file(s) in default search path"]
                sources_list.extend(f"Dynaconf file {dynaconf_msg}: {x}" for x in dynaconf_file_list)

                # Convert to string
                settings_sources_str = "n".join(f"    - {x}" for x in sources_list)

                # List of missing required fields
                fields_error_msg_list = [
                    f"    - '{envvar_prefix}_{k.upper()}' (envvar/.env) or '{prefix}_{k}' (Dynaconf)"
                    for k in missing_fields
                ]
                fields_error_msg_str = "n".join(fields_error_msg_list)

                # Raise exception with detailed information
                raise ValueError(
                    f"Required settings field(s) for {cls.__name__} not found:n{fields_error_msg_str}n"
                    f"Settings sources searched in the order of priority:n{settings_sources_str}"
                )

            # TODO: Add a check for nested complex types in settings, if these are present deserialization will fail
            # TODO: Can custom deserializer that removes trailing and leading _ can be used without cyclic reference?
            result = cls(**settings_dict)

            # Invoke init method for each class hierarchy member from base to derived
            RecordUtil.init_all(result)

            # Cache the result
            cls.__settings_dict[cls] = result

        return result

    @classmethod
    def get_project_root(cls) -> str:  # TODO: Merge with the version from ProjectSettings
        """
        Returns absolute path of the directory containing .env file, and if not present the directory
        containing the first Dynaconf settings file found. Error message if neither is found.
        """
        if _dotenv_dir_path is not None:
            # Use .env file location if found
            return _dotenv_dir_path
        elif _dynaconf_dir_path is not None:
            # Otherwise use the location of the first Dynaconf file found
            # TODO: Add a test to confirm the logic when several Dynaconf files are in different locations
            return _dynaconf_dir_path
        else:
            raise RuntimeError(
                "Cannot get project root because neither .env file nor dynaconf settings file are found. "
                "Project root is defined based on the location of these two files (with .env having a priority)."
            )

    @classmethod
    def normalize_paths(cls, field_name: str, field_value: Iterable[str] | str | None) -> List[str]:
        """
        Convert to absolute path if path relative to the location of .env or Dynaconf file is specified
        and convert to list if single value is specified.
        """

        # Check that the argument is either None, a string or, an iterable
        if field_value is None:
            # Accept None and treat it as an empty list
            return []
        elif isinstance(field_value, str):
            paths = [field_value]
        elif hasattr(field_value, "__iter__"):
            paths = list(field_value)
        else:
            raise RuntimeError(
                f"Field '{field_name}' with value '{field_value}' in class '{cls.__name__}' "
                f"must be a string or an iterable of strings."
            )

        result = [cls.normalize_path(field_name, path) for path in paths]
        return result

    @classmethod
    def normalize_path(cls, field_name: str, field_value: str | None) -> str:
        """Convert to absolute path if path relative to the location of .env or Dynaconf file is specified."""

        if field_value is None or field_value == "":
            raise RuntimeError(f"Field '{field_name}' in class '{cls.__name__}' has an empty element.")
        elif isinstance(field_value, str):
            # Check that 'field_value' is a string
            result = field_value
        else:
            raise RuntimeError(
                f"Field '{field_name}' in class '{cls.__name__}' has an element "
                f"with type {type(field_value)} which is not a string."
            )

        if not os.path.isabs(result):
            project_root = cls.get_project_root()
            result = os.path.join(project_root, result)

        # Return as a normalized path string
        result = os.path.normpath(result)
        return result

Classes

class Settings

Base class for a singleton settings object.

Expand source code
@dataclass(slots=True, kw_only=True)
class Settings(ABC):
    """Base class for a singleton settings object."""

    process_timestamp: ClassVar[str] = _process_timestamp
    """Unique UUIDv7-based timestamp set during the Python process launch."""

    is_inside_test: ClassVar[bool] = _is_inside_test
    """True if we are inside a test."""

    __settings_dict: ClassVar[Dict[Type, Settings]] = {}
    """Dictionary of initialized settings objects indexed by the the settings class type."""

    @classmethod
    @abstractmethod
    def get_prefix(cls) -> str:
        """
        Dynaconf fields will be filtered by 'prefix_' before being passed to the settings class constructor.

        Notes:
            - The prefix must be lowercase
            - The prefix must not start or end with underscore but may include underscore separator(s)
            - The prefix is removed before the fields are provided to the constructor of this settings class
        """

    @classmethod
    def instance(cls) -> Self:
        """Return singleton instance."""

        # Check if cached value exists, load if not found
        if (result := cls.__settings_dict.get(cls, None)) is None:
            # A settings class may specify an optional prefix used to filter dynaconf fields
            prefix = cls.get_prefix()

            # Validate prefix
            prefix_description = f"Dynaconf settings prefix '{prefix}' returned by '{cls.__name__}.get_prefix()'"
            if prefix is None:
                raise RuntimeError(f"{prefix_description} is None.")
            if prefix == "":
                raise RuntimeError(f"{prefix_description} is an empty string.")
            if not prefix.islower():
                raise RuntimeError(f"{prefix_description} must be lowercase.")
            if prefix.startswith("_"):
                raise RuntimeError(f"{prefix_description} must not start with an underscore.")
            if prefix.endswith("_"):
                raise RuntimeError(f"{prefix_description} must not end with an underscore.")

            # List of required fields in cls (fields for which neither default nor default_factory is specified)
            required_fields = [
                name
                for name, field_info in cls.__dataclass_fields__.items()  # noqa
                if field_info.default is MISSING and field_info.default_factory is MISSING
            ]

            # Filter user settings by 'prefix_' and create a new dictionary where prefix is removed from keys
            # This will include fields that are not specified in the settings class
            p = prefix + "_"
            settings_dict = {k[len(p) :]: v for k, v in _user_settings.items() if k.startswith(p)}

            # Check for missing required fields
            missing_fields = [k for k in required_fields if k not in settings_dict]
            if missing_fields:
                # Combine the global Dynaconf envvar prefix with settings prefix in uppercase
                envvar_prefix = f"{_dynaconf_envvar_prefix}_{prefix.upper()}"
                dynaconf_msg = f"(in lowercase with prefix '{prefix}_')"
                envvar_msg = f"(in uppercase with prefix '{envvar_prefix}_')"

                # Environment variables
                sources_list = [f"Environment variables {envvar_msg}"]

                # Dotenv file or message that it is not found
                if (env_file := find_dotenv()) != "":
                    env_file_name = env_file
                else:
                    env_file_name = "No .env file in default search path"
                sources_list.append(f"Dotenv file {envvar_msg}: {env_file_name}")

                # Dynaconf file(s) or message that they are not found
                if _dynaconf_loaded_files:
                    dynaconf_file_list = _dynaconf_loaded_files
                else:
                    _dynaconf_file_patterns_str = ", ".join(_dynaconf_file_patterns)
                    dynaconf_file_list = [f"No {_dynaconf_file_patterns_str} file(s) in default search path"]
                sources_list.extend(f"Dynaconf file {dynaconf_msg}: {x}" for x in dynaconf_file_list)

                # Convert to string
                settings_sources_str = "n".join(f"    - {x}" for x in sources_list)

                # List of missing required fields
                fields_error_msg_list = [
                    f"    - '{envvar_prefix}_{k.upper()}' (envvar/.env) or '{prefix}_{k}' (Dynaconf)"
                    for k in missing_fields
                ]
                fields_error_msg_str = "n".join(fields_error_msg_list)

                # Raise exception with detailed information
                raise ValueError(
                    f"Required settings field(s) for {cls.__name__} not found:n{fields_error_msg_str}n"
                    f"Settings sources searched in the order of priority:n{settings_sources_str}"
                )

            # TODO: Add a check for nested complex types in settings, if these are present deserialization will fail
            # TODO: Can custom deserializer that removes trailing and leading _ can be used without cyclic reference?
            result = cls(**settings_dict)

            # Invoke init method for each class hierarchy member from base to derived
            RecordUtil.init_all(result)

            # Cache the result
            cls.__settings_dict[cls] = result

        return result

    @classmethod
    def get_project_root(cls) -> str:  # TODO: Merge with the version from ProjectSettings
        """
        Returns absolute path of the directory containing .env file, and if not present the directory
        containing the first Dynaconf settings file found. Error message if neither is found.
        """
        if _dotenv_dir_path is not None:
            # Use .env file location if found
            return _dotenv_dir_path
        elif _dynaconf_dir_path is not None:
            # Otherwise use the location of the first Dynaconf file found
            # TODO: Add a test to confirm the logic when several Dynaconf files are in different locations
            return _dynaconf_dir_path
        else:
            raise RuntimeError(
                "Cannot get project root because neither .env file nor dynaconf settings file are found. "
                "Project root is defined based on the location of these two files (with .env having a priority)."
            )

    @classmethod
    def normalize_paths(cls, field_name: str, field_value: Iterable[str] | str | None) -> List[str]:
        """
        Convert to absolute path if path relative to the location of .env or Dynaconf file is specified
        and convert to list if single value is specified.
        """

        # Check that the argument is either None, a string or, an iterable
        if field_value is None:
            # Accept None and treat it as an empty list
            return []
        elif isinstance(field_value, str):
            paths = [field_value]
        elif hasattr(field_value, "__iter__"):
            paths = list(field_value)
        else:
            raise RuntimeError(
                f"Field '{field_name}' with value '{field_value}' in class '{cls.__name__}' "
                f"must be a string or an iterable of strings."
            )

        result = [cls.normalize_path(field_name, path) for path in paths]
        return result

    @classmethod
    def normalize_path(cls, field_name: str, field_value: str | None) -> str:
        """Convert to absolute path if path relative to the location of .env or Dynaconf file is specified."""

        if field_value is None or field_value == "":
            raise RuntimeError(f"Field '{field_name}' in class '{cls.__name__}' has an empty element.")
        elif isinstance(field_value, str):
            # Check that 'field_value' is a string
            result = field_value
        else:
            raise RuntimeError(
                f"Field '{field_name}' in class '{cls.__name__}' has an element "
                f"with type {type(field_value)} which is not a string."
            )

        if not os.path.isabs(result):
            project_root = cls.get_project_root()
            result = os.path.join(project_root, result)

        # Return as a normalized path string
        result = os.path.normpath(result)
        return result

Ancestors

  • abc.ABC

Subclasses

Class variables

var is_inside_test

True if we are inside a test.

var process_timestamp

Unique UUIDv7-based timestamp set during the Python process launch.

Static methods

def get_prefix() -> str

Dynaconf fields will be filtered by ‘prefix_’ before being passed to the settings class constructor.

Notes

  • The prefix must be lowercase
  • The prefix must not start or end with underscore but may include underscore separator(s)
  • The prefix is removed before the fields are provided to the constructor of this settings class
def get_project_root() -> str

Returns absolute path of the directory containing .env file, and if not present the directory containing the first Dynaconf settings file found. Error message if neither is found.

def instance() -> Self

Return singleton instance.

def normalize_path(field_name: str, field_value: str | None) -> str

Convert to absolute path if path relative to the location of .env or Dynaconf file is specified.

def normalize_paths(field_name: str, field_value: Iterable[str] | str | None) -> List[str]

Convert to absolute path if path relative to the location of .env or Dynaconf file is specified and convert to list if single value is specified.