diff --git a/.gitignore b/.gitignore index 807c1b8..1bbddce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ # Ignore test plots tests/plots - -# Ignore schema, which has absolute paths that will vary by system -params_schema.json +# Ignore schemas, which have absolute paths that will vary by system +*schema.json +# Ignore local dev environment settings +*.env +settings*.yaml # * -------------------------------------------------------------------------------- * # # * Template gitignore diff --git a/.vscode/settings.json b/.vscode/settings.json index c47d8c8..ab1e99c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,16 @@ // * ----------------------------------------------------------------------------- * # //! Schema "yaml.schemas": { - "params_schema.json": "params.yaml" + "params_schema.json": "params.yaml", + // ? `boilercore` + "src/boilercore/settings_schema.json": "src/boilercore/settings.yaml", + "src/boilercore/settings_plugin_schema.json": "src/boilercore/settings_plugin.yaml", + // ? `boilercore_docs` + "docs/boilercore_docs/settings_schema.json": "docs/boilercore_docs/settings.yaml", + "docs/boilercore_docs/settings_plugin_schema.json": "docs/boilercore_docs/settings_plugin.yaml", + // ? `boilercore_tests` + "tests/boilercore_tests/settings_schema.json": "tests/boilercore_tests/settings.yaml", + "tests/boilercore_tests/settings_plugin_schema.json": "tests/boilercore_tests/settings_plugin.yaml" }, // * ----------------------------------------------------------------------------- * # //! Terminal diff --git a/docs/boilercore_docs/settings.py b/docs/boilercore_docs/settings.py new file mode 100644 index 0000000..97da64f --- /dev/null +++ b/docs/boilercore_docs/settings.py @@ -0,0 +1,60 @@ +"""Settings.""" + +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, +) + +import boilercore_docs +from boilercore.settings_models import ( + customise_sources, + get_settings_paths, + sync_settings_schema, +) + +paths = get_settings_paths(boilercore_docs) + + +class PluginModelConfig(BaseSettings): + """Pydantic plugin model configuration.""" + + model_config = SettingsConfigDict(use_attribute_docstrings=True) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + *_args: PydanticBaseSettingsSource, + **_kwds: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Source settings from init and TOML.""" + return customise_sources(settings_cls, init_settings, paths.plugin_settings) + + +class Settings(BaseSettings): + """Package settings.""" + + model_config = SettingsConfigDict(use_attribute_docstrings=True) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + *_args: PydanticBaseSettingsSource, + **_kwds: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Source settings from init and TOML.""" + return customise_sources(settings_cls, init_settings, paths.plugin_settings) + + +for path, model in zip( + paths.all_dev_settings if paths.in_dev else paths.all_cwd_settings, + (PluginModelConfig, Settings), + strict=True, +): + sync_settings_schema(path, model) + +default = Settings() diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 649fb4f..c4fa0f2 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "matplotlib>=3.7.2", "numpy>=1.24.4", "pandas[hdf5,performance]>=2.2.2", + "pydantic-settings>=2.4.0", "seaborn>=0.13.2", "sympy>=1.12", ] diff --git a/src/boilercore/settings.py b/src/boilercore/settings.py new file mode 100644 index 0000000..f83c853 --- /dev/null +++ b/src/boilercore/settings.py @@ -0,0 +1,58 @@ +"""Settings.""" + +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, +) + +import boilercore +from boilercore.settings_models import ( + customise_sources, + get_settings_paths, + sync_settings_schema, +) + +paths = get_settings_paths(boilercore) + + +class PluginModelConfig(BaseSettings): + """Pydantic plugin model configuration.""" + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + *_args: PydanticBaseSettingsSource, + **_kwds: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Source settings from init and TOML.""" + return customise_sources(settings_cls, init_settings, paths.plugin_settings) + + +class Settings(BaseSettings): + """Package settings.""" + + model_config = SettingsConfigDict(use_attribute_docstrings=True) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + *_args: PydanticBaseSettingsSource, + **_kwds: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Source settings from init and TOML.""" + return customise_sources(settings_cls, init_settings, paths.settings) + + +for path, model in zip( + paths.all_dev_settings if paths.in_dev else paths.all_cwd_settings, + (PluginModelConfig, Settings), + strict=True, +): + sync_settings_schema(path, model) + +default = Settings() diff --git a/src/boilercore/settings_models.py b/src/boilercore/settings_models.py new file mode 100644 index 0000000..885cdd5 --- /dev/null +++ b/src/boilercore/settings_models.py @@ -0,0 +1,107 @@ +"""Settings models.""" + +from collections.abc import Iterable +from json import dumps +from pathlib import Path +from site import getsitepackages +from types import ModuleType + +from pydantic import BaseModel +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + YamlConfigSettingsSource, +) + +from boilercore.paths import get_module_name, get_package_dir + + +class Paths(BaseModel): + """Settings model paths.""" + + cwd_plugin_settings: Path + dev_plugin_settings: Path + plugin_settings: list[Path] + + cwd_settings: Path + dev_settings: Path + settings: list[Path] + + all_cwd_settings: list[Path] + all_dev_settings: list[Path] + + in_dev: bool + + +def get_settings_paths(module: ModuleType) -> Paths: + """Get settings model paths.""" + package_dir = get_package_dir(module) + package_name = get_module_name(module) + return Paths( + cwd_plugin_settings=( + cwd_plugin_settings := Path.cwd() / f"{package_name}_plugin.yaml" + ), + dev_plugin_settings=( + dev_plugin_settings := package_dir / "settings_plugin.yaml" + ), + plugin_settings=[cwd_plugin_settings, dev_plugin_settings], + cwd_settings=(cwd_settings := Path.cwd() / Path(f"{package_name}.yaml")), + dev_settings=(dev_settings := package_dir / "settings.yaml"), + settings=[cwd_settings, dev_settings], + all_cwd_settings=[cwd_plugin_settings, cwd_settings], + all_dev_settings=[dev_plugin_settings, dev_settings], + in_dev=not ( + getsitepackages() and package_dir.is_relative_to(Path(getsitepackages()[0])) + ), + ) + + +def get_yaml_sources( + settings_cls: type[BaseSettings], paths: Iterable[Path], encoding: str = "utf-8" +) -> tuple[YamlConfigSettingsSource, ...]: + """Source settings from init and TOML.""" + sources: list[YamlConfigSettingsSource] = [] + for yaml_file in paths: + source = YamlConfigSettingsSource( + settings_cls, yaml_file, yaml_file_encoding=encoding + ) + if source.init_kwargs.get("$schema"): + del source.init_kwargs["$schema"] + sources.append(source) + return tuple(sources) + + +def customise_sources( + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + yaml_files: Iterable[Path], + encoding: str = "utf-8", +): + """Source settings from init and YAML.""" + return (init_settings, *get_yaml_sources(settings_cls, yaml_files, encoding)) + + +def get_plugin_settings( + package_name: str, config: BaseSettings +) -> dict[str, BaseSettings]: + """Get Pydantic plugin model configuration. + + ```Python + model_config = SettingsConfigDict( + plugin_settings={"boilercv_docs": PluginModelConfig()} + ) + ``` + """ + return {package_name: config} + + +def sync_settings_schema( + path: Path, model: type[BaseModel], encoding: str = "utf-8" +) -> None: + """Create settings file and update its schema.""" + if not path.exists(): + path.touch() + schema = path.with_name(f"{path.stem}_schema.json") + schema.write_text( + encoding=encoding, data=f"{dumps(model.model_json_schema(), indent=2)}\n" + )