diff --git a/yaml_settings_pydantic/__init__.py b/yaml_settings_pydantic/__init__.py index 3cede6e..9b5e117 100644 --- a/yaml_settings_pydantic/__init__.py +++ b/yaml_settings_pydantic/__init__.py @@ -15,73 +15,23 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar - -from pydantic_settings import BaseSettings, PydanticBaseSettingsSource - -from yaml_settings_pydantic import loader, util from yaml_settings_pydantic.loader import ( DEFAULT_YAML_FILE_CONFIG_DICT, YamlFileConfigDict, YamlSettingsConfigDict, resolve_filepaths, ) -from yaml_settings_pydantic.settings import CreateYamlSettings +from yaml_settings_pydantic.manifests import BaseYaml +from yaml_settings_pydantic.settings import BaseYamlSettings, CreateYamlSettings __version__ = "2.3.1" -logger = util.get_logger(__name__) - - -class BaseYamlSettings(BaseSettings): - """YAML Settings. - - Dunder classvars and ``model_config`` determine how and what is loaded. - - :attr model_config: Secondary source for dunder (`__`) prefixed values. - This should be an instance of :class:`YamlSettingsConfigDict` for - optimal editor feedback. - :attr __yaml_reload__: Reload files when constructor is called. - Overwrites `model_config["yaml_reload"]`. - :attr __yaml_files__: All of the files to load to populate - settings fields (in order of ascending importance). Overwrites - `model_config["yaml_reload"]`. - """ - - if TYPE_CHECKING: - # NOTE: pydantic>=2.7 checks at load time for annotated fields, and - # thinks that `model_config` is a model field name. - model_config: ClassVar[loader.YamlSettingsConfigDict] - - @classmethod - def settings_customise_sources( - cls, - settings_cls: type[BaseSettings], - init_settings: PydanticBaseSettingsSource, - env_settings: PydanticBaseSettingsSource, - dotenv_settings: PydanticBaseSettingsSource, - file_secret_settings: PydanticBaseSettingsSource, - ) -> tuple[PydanticBaseSettingsSource, ...]: - """Customizes sources for configuration. See `the pydantic docs`_.""" - - # Look for YAML files. - logger.debug("Creating YAML settings callable for `%s`.", cls.__name__) - yaml_settings = CreateYamlSettings(settings_cls) - - # The order in which these appear determines their precendence. So a - # `.env` file could be added to # override the ``YAML`` configuration - return ( - init_settings, - env_settings, - dotenv_settings, - file_secret_settings, - yaml_settings, - ) __all__ = ( "resolve_filepaths", - "CreateYamlSettings", + "BaseYaml", "BaseYamlSettings", + "CreateYamlSettings", "YamlSettingsConfigDict", "YamlFileConfigDict", "DEFAULT_YAML_FILE_CONFIG_DICT", diff --git a/yaml_settings_pydantic/loader.py b/yaml_settings_pydantic/loader.py index d6f6210..a23c9ab 100644 --- a/yaml_settings_pydantic/loader.py +++ b/yaml_settings_pydantic/loader.py @@ -2,14 +2,14 @@ from collections.abc import Sequence from os import environ -from pathlib import Path, PosixPath -from typing import Annotated, Any, NotRequired, Set, TypedDict +from pathlib import Path +from typing import Annotated, Any import jsonpath_ng import yaml from pydantic.v1.utils import deep_update from pydantic_settings import SettingsConfigDict -from typing_extensions import Doc +from typing_extensions import Doc, NotRequired, TypedDict class YamlFileConfigDict(TypedDict, total=False): @@ -154,8 +154,8 @@ def validate_yaml_settings_config_files( keys_invalid = {item for item in values if not isinstance(item, Path)} if len(keys_invalid): raise ValueError( - "All items in `files` must be strings. The following are " - f"not strings: `{keys_invalid}`." + "All items in `values` must have type `Path`. The following are " + f"not of type `Path`: `{keys_invalid}`." ) # NOTE: Create dictionary if the sequence is not a dictionary. @@ -182,7 +182,7 @@ def validate_yaml_settings_config_files( def load_yaml_data( - yaml_file_configs: dict[Path, YamlFileConfigDict] + yaml_file_configs: dict[Path, YamlFileConfigDict], ) -> dict[Path, YamlFileData]: """Load data without validatation. @@ -266,10 +266,15 @@ def validate_yaml_data_content( def validate_yaml_data( yaml_data: dict[Path, YamlFileData], + overwrite: dict[str, Any] | None = None, + exclude: dict[str, Any] | set[str] | None = None, ) -> dict[str, Any]: """Extract subpath from loaded YAML. - :param loaded: Loaded YAML files from :attr:`files`. + :param overwrite: Overwriting values. + :param exclude: Values to exclude from merged data. When this value is a + ``dict``, the dictionary values will be put in the place of the values + provided. :raises: `ValueError` when the subpaths cannot be found or when documents do not deserialize to dictionaries at their subpath. :returns: :param:`Loaded` with the subpath extracted. @@ -298,5 +303,22 @@ def validate_yaml_data( ) raise ValueError(msg) - # logger.debug("Merging file results.") - return deep_update(*content) + # NOTE: Add overwriters if there are any, return if not overwrites. + if overwrite is not None: + content = (*content, overwrite) + + data = deep_update(*content) + if exclude is None: + return data + + if len(bad := {field for field in exclude if data.get(field) is not None}): + msg_fmt = "Helm values must not specify `{}`." + raise ValueError(msg_fmt.format(bad)) + + if not isinstance(exclude, dict): + return data + + # NOTE: Adding ``None`` values in exclude will result in the field not + # being set. + data = deep_update(data, {k: v for k, v in exclude.items() if v is not None}) + return data diff --git a/yaml_settings_pydantic/manifests.py b/yaml_settings_pydantic/manifests.py new file mode 100644 index 0000000..2a155f0 --- /dev/null +++ b/yaml_settings_pydantic/manifests.py @@ -0,0 +1,36 @@ +"""For non settings/configuration files. +""" + +from __future__ import annotations + +from typing import Any, Self + +from pydantic import BaseModel + +from yaml_settings_pydantic import loader + + +class BaseYaml(BaseModel): + """Pydantic model loadable from yaml. See :meth:`load`.""" + + @classmethod + def load( + cls, + *, + overwrite: dict[str, Any] | None = None, + exclude: dict[str, Any] | None = None, + **yaml_settings: loader.YamlSettingsConfigDict, + ) -> Self: + + if (yaml_files := yaml_settings.get("yaml_files")) is None: + raise ValueError() + + yaml_files_config = loader.validate_yaml_settings_config_files(yaml_files) + yaml_files_data = loader.load_yaml_data(yaml_files_config) + data = loader.validate_yaml_data( + yaml_files_data, + overwrite=overwrite, + exclude=exclude, + ) + + return cls.model_validate(data) diff --git a/yaml_settings_pydantic/settings.py b/yaml_settings_pydantic/settings.py index 70f106f..e55bd46 100644 --- a/yaml_settings_pydantic/settings.py +++ b/yaml_settings_pydantic/settings.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from pathlib import Path -from typing import Annotated, Any +from typing import TYPE_CHECKING, Annotated, Any, ClassVar from pydantic.fields import FieldInfo from pydantic_settings import BaseSettings, PydanticBaseSettingsSource @@ -22,7 +24,7 @@ class CreateYamlSettings(PydanticBaseSettingsSource): bool, Doc( "When ``True```, reload files specified in :param:`files` when a " - "new instance is created. Default is `False`." + "new instance is created. Default is ``False``." ), ] yaml_files_configs: Annotated[ @@ -105,3 +107,49 @@ def get_field_value( v = self.loaded.get(field_name) return (v, field_name, False) + + +class BaseYamlSettings(BaseSettings): + """YAML Settings. + + Dunder classvars and ``model_config`` determine how and what is loaded. + + :attr model_config: Secondary source for dunder (`__`) prefixed values. + This should be an instance of :class:`YamlSettingsConfigDict` for + optimal editor feedback. + :attr __yaml_reload__: Reload files when constructor is called. + Overwrites `model_config["yaml_reload"]`. + :attr __yaml_files__: All of the files to load to populate + settings fields (in order of ascending importance). Overwrites + `model_config["yaml_reload"]`. + """ + + if TYPE_CHECKING: + # NOTE: pydantic>=2.7 checks at load time for annotated fields, and + # thinks that `model_config` is a model field name. + model_config: ClassVar[loader.YamlSettingsConfigDict] + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Customizes sources for configuration. See `the pydantic docs`_.""" + + # Look for YAML files. + logger.debug("Creating YAML settings callable for `%s`.", cls.__name__) + yaml_settings = CreateYamlSettings(settings_cls) + + # The order in which these appear determines their precendence. So a + # `.env` file could be added to # override the ``YAML`` configuration + return ( + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + yaml_settings, + )