Skip to content

Commit

Permalink
wip(manifests): Added manifests.py and loader kwargs.
Browse files Browse the repository at this point in the history
This should work for #22.
  • Loading branch information
acederberg committed Jul 18, 2024
1 parent 6a78363 commit 0c425bd
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 65 deletions.
58 changes: 4 additions & 54 deletions yaml_settings_pydantic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<https://docs.pydantic.dev/latest/usage/pydantic_settings/#customise-settings-sources>`_."""

# 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",
Expand Down
40 changes: 31 additions & 9 deletions yaml_settings_pydantic/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
36 changes: 36 additions & 0 deletions yaml_settings_pydantic/manifests.py
Original file line number Diff line number Diff line change
@@ -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)
52 changes: 50 additions & 2 deletions yaml_settings_pydantic/settings.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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[
Expand Down Expand Up @@ -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<https://docs.pydantic.dev/latest/usage/pydantic_settings/#customise-settings-sources>`_."""

# 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,
)

0 comments on commit 0c425bd

Please sign in to comment.