Skip to content

Commit

Permalink
Config classes to API (#582)
Browse files Browse the repository at this point in the history
  • Loading branch information
maddenp-noaa authored Aug 20, 2024
1 parent edc3b83 commit a99fe8f
Show file tree
Hide file tree
Showing 18 changed files with 397 additions and 379 deletions.
2 changes: 1 addition & 1 deletion docs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ BUILDDIR = build
LINKCHECKDIR = $(BUILDDIR)/linkcheck
SOURCEDIR = .
SPHINXBUILD = sphinx-build
SPHINXOPTS = -a -n -W
SPHINXOPTS = -a -n -W --keep-going

.PHONY: help clean docs examples linkcheck

Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
html_theme = "sphinx_rtd_theme"
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
linkcheck_ignore = [r"https://github.com/.*#.*"]
nitpick_ignore_regex = [("py:class", r"^uwtools\..*")]
nitpick_ignore_regex = [("py:class", r"^uwtools\..*"), ("py:class", "f90nml.Namelist")]
numfig = True
numfig_format = {"figure": "Figure %s"}
project = "Unified Workflow Tools"
Expand Down
1 change: 1 addition & 0 deletions docs/sections/user_guide/api/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
======================

.. automodule:: uwtools.api.config
:inherited-members: UserDict
:members:
51 changes: 34 additions & 17 deletions src/uwtools/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
from pathlib import Path
from typing import Optional, Union

from uwtools.config.formats.fieldtable import FieldTableConfig as _FieldTableConfig
from uwtools.config.formats.ini import INIConfig as _INIConfig
from uwtools.config.formats.nml import NMLConfig as _NMLConfig
from uwtools.config.formats.sh import SHConfig as _SHConfig
from uwtools.config.formats.yaml import Config as _Config
from uwtools.config.formats.yaml import YAMLConfig as _YAMLConfig
from uwtools.config.formats.base import Config as _Config
from uwtools.config.formats.fieldtable import FieldTableConfig
from uwtools.config.formats.ini import INIConfig
from uwtools.config.formats.nml import NMLConfig
from uwtools.config.formats.sh import SHConfig
from uwtools.config.formats.yaml import YAMLConfig
from uwtools.config.tools import compare_configs as _compare
from uwtools.config.tools import realize_config as _realize
from uwtools.config.validator import validate_external as _validate_external
Expand Down Expand Up @@ -42,71 +42,71 @@ def compare(

def get_fieldtable_config(
config: Union[dict, Optional[Union[Path, str]]] = None, stdin_ok=False
) -> _FieldTableConfig:
) -> FieldTableConfig:
"""
Get a ``FieldTableConfig`` object.
:param config: FieldTable file (``None`` => read ``stdin``), or initial ``dict``.
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``FieldTableConfig`` object.
"""
return _FieldTableConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
return FieldTableConfig(config=_ensure_data_source(_str2path(config), stdin_ok))


def get_ini_config(
config: Union[dict, Optional[Union[Path, str]]] = None,
stdin_ok: bool = False,
) -> _INIConfig:
) -> INIConfig:
"""
Get an ``INIConfig`` object.
:param config: INI file or ``dict`` (``None`` => read ``stdin``).
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``INIConfig`` object.
"""
return _INIConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
return INIConfig(config=_ensure_data_source(_str2path(config), stdin_ok))


def get_nml_config(
config: Union[dict, Optional[Union[Path, str]]] = None,
stdin_ok: bool = False,
) -> _NMLConfig:
) -> NMLConfig:
"""
Get an ``NMLConfig`` object.
:param config: Namelist file of ``dict`` (``None`` => read ``stdin``).
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``NMLConfig`` object.
"""
return _NMLConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
return NMLConfig(config=_ensure_data_source(_str2path(config), stdin_ok))


def get_sh_config(
config: Union[dict, Optional[Union[Path, str]]] = None,
stdin_ok: bool = False,
) -> _SHConfig:
) -> SHConfig:
"""
Get an ``SHConfig`` object.
:param config: Shell key=value pairs file or ``dict`` (``None`` => read ``stdin``).
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``SHConfig`` object.
"""
return _SHConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
return SHConfig(config=_ensure_data_source(_str2path(config), stdin_ok))


def get_yaml_config(
config: Union[dict, Optional[Union[Path, str]]] = None,
stdin_ok: bool = False,
) -> _YAMLConfig:
) -> YAMLConfig:
"""
Get a ``YAMLConfig`` object.
:param config: YAML file or ``dict`` (``None`` => read ``stdin``).
:param stdin_ok: OK to read from ``stdin``?
:return: An initialized ``YAMLConfig`` object.
"""
return _YAMLConfig(config=_ensure_data_source(_str2path(config), stdin_ok))
return YAMLConfig(config=_ensure_data_source(_str2path(config), stdin_ok))


def realize(
Expand Down Expand Up @@ -160,7 +160,7 @@ def realize_to_dict( # pylint: disable=unused-argument

def validate(
schema_file: Union[Path, str],
config: Optional[Union[dict, _YAMLConfig, Path, str]] = None,
config: Optional[Union[dict, YAMLConfig, Path, str]] = None,
stdin_ok: bool = False,
) -> bool:
"""
Expand Down Expand Up @@ -246,3 +246,20 @@ def validate(
""".format(
extensions=", ".join(_FORMAT.extensions())
).strip()

__all__ = [
"FieldTableConfig",
"INIConfig",
"NMLConfig",
"SHConfig",
"YAMLConfig",
"compare",
"get_fieldtable_config",
"get_ini_config",
"get_nml_config",
"get_sh_config",
"get_yaml_config",
"realize",
"realize_to_dict",
"validate",
]
158 changes: 77 additions & 81 deletions src/uwtools/config/formats/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from __future__ import annotations

import os
import re
from abc import ABC, abstractmethod
Expand All @@ -25,8 +23,6 @@ class Config(ABC, UserDict):

def __init__(self, config: Optional[Union[dict, str, Path]] = None) -> None:
"""
Construct a Config object.
:param config: Config file to load (None => read from stdin), or initial dict.
"""
super().__init__()
Expand All @@ -36,10 +32,10 @@ def __init__(self, config: Optional[Union[dict, str, Path]] = None) -> None:
else:
self._config_file = str2path(config) if config else None
self.data = self._load(self._config_file)
if self.get_depth_threshold() and self.depth != self.get_depth_threshold():
if self._get_depth_threshold() and self._depth != self._get_depth_threshold():
raise UWConfigError(
"Cannot instantiate depth-%s %s with depth-%s config"
% (self.get_depth_threshold(), type(self).__name__, self.depth)
% (self._get_depth_threshold(), type(self).__name__, self._depth)
)

def __repr__(self) -> str:
Expand All @@ -50,6 +46,43 @@ def __repr__(self) -> str:

# Private methods

def _characterize_values(self, values: dict, parent: str) -> tuple[list, list]:
"""
Characterize values as complete or as template placeholders.
:param values: The dictionary to examine.
:param parent: Parent key.
:return: Lists of of complete and template-placeholder values.
"""
complete: list[str] = []
template: list[str] = []
for key, val in values.items():
if isinstance(val, dict):
complete.append(f"{INDENT}{parent}{key}")
c, t = self._characterize_values(val, f"{parent}{key}.")
complete, template = complete + c, template + t
elif isinstance(val, list):
for item in val:
if isinstance(item, dict):
c, t = self._characterize_values(item, parent)
complete, template = complete + c, template + t
complete.append(f"{INDENT}{parent}{key}")
elif "{{" in str(val) or "{%" in str(val):
template.append(f"{INDENT}{parent}{key}: {val}")
break
elif "{{" in str(val) or "{%" in str(val):
template.append(f"{INDENT}{parent}{key}: {val}")
else:
complete.append(f"{INDENT}{parent}{key}")
return complete, template

@property
def _depth(self) -> int:
"""
Returns the depth of this config's hierarchy.
"""
return depth(self.data)

@classmethod
@abstractmethod
def _dict_to_str(cls, cfg: dict) -> str:
Expand All @@ -59,6 +92,20 @@ def _dict_to_str(cls, cfg: dict) -> str:
:param cfg: A dict object.
"""

@staticmethod
@abstractmethod
def _get_depth_threshold() -> Optional[int]:
"""
Returns the config's depth threshold.
"""

@staticmethod
@abstractmethod
def _get_format() -> str:
"""
Returns the config's format name.
"""

@abstractmethod
def _load(self, config_file: Optional[Path]) -> dict:
"""
Expand Down Expand Up @@ -88,37 +135,28 @@ def _load_paths(self, config_files: list[Path]) -> dict:
cfg.update(self._load(config_file=config_file))
return cfg

# Public methods

def characterize_values(self, values: dict, parent: str) -> tuple[list, list]:
def _parse_include(self, ref_dict: Optional[dict] = None) -> None:
"""
Characterize values as complete or as template placeholders.
Recursively process include directives in a config object.
:param values: The dictionary to examine.
:param parent: Parent key.
:return: Lists of of complete and template-placeholder values.
Recursively traverse the dictionary, replacing include tags with the contents of the files
they specify. Assumes a section/key/value structure. YAML provides this functionality in its
own loader.
:param ref_dict: A config object to process instead of the object's own data.
"""
complete: list[str] = []
template: list[str] = []
for key, val in values.items():
if isinstance(val, dict):
complete.append(f"{INDENT}{parent}{key}")
c, t = self.characterize_values(val, f"{parent}{key}.")
complete, template = complete + c, template + t
elif isinstance(val, list):
for item in val:
if isinstance(item, dict):
c, t = self.characterize_values(item, parent)
complete, template = complete + c, template + t
complete.append(f"{INDENT}{parent}{key}")
elif "{{" in str(val) or "{%" in str(val):
template.append(f"{INDENT}{parent}{key}: {val}")
break
elif "{{" in str(val) or "{%" in str(val):
template.append(f"{INDENT}{parent}{key}: {val}")
else:
complete.append(f"{INDENT}{parent}{key}")
return complete, template
if ref_dict is None:
ref_dict = self.data
for key, value in deepcopy(ref_dict).items():
if isinstance(value, dict):
self._parse_include(ref_dict[key])
elif isinstance(value, str):
if m := re.match(r"^\s*%s\s+(.*)" % INCLUDE_TAG, value):
filepaths = yaml.safe_load(m[1])
self.update_from(self._load_paths(filepaths))
del ref_dict[key]

# Public methods

def compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool:
"""
Expand All @@ -127,7 +165,7 @@ def compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool:
Assumes a section/key/value structure.
:param dict1: The first dictionary.
:param dict2: The second dictionary.
:param dict2: The second dictionary (default: this config).
:return: True if the configs are identical, False otherwise.
"""
dict2 = self.data if dict2 is None else dict2
Expand Down Expand Up @@ -158,13 +196,6 @@ def compare_config(self, dict1: dict, dict2: Optional[dict] = None) -> bool:

return not diffs

@property
def depth(self) -> int:
"""
Returns the depth of this config's hierarchy.
"""
return depth(self.data)

def dereference(self, context: Optional[dict] = None) -> None:
"""
Render as much Jinja2 syntax as possible.
Expand All @@ -189,14 +220,7 @@ def dump(self, path: Optional[Path]) -> None:
"""
Dumps the config to stdout or a file.
:param path: Path to dump config to.
"""

@staticmethod
@abstractmethod
def get_depth_threshold() -> Optional[int]:
"""
Returns the config's depth threshold.
:param path: Path to dump config to (default: stdout).
"""

@staticmethod
Expand All @@ -206,38 +230,10 @@ def dump_dict(cfg: dict, path: Optional[Path] = None) -> None:
Dumps a provided config dictionary to stdout or a file.
:param cfg: The in-memory config object to dump.
:param path: Path to dump config to.
"""

@staticmethod
@abstractmethod
def get_format() -> str:
"""
Returns the config's format name.
:param path: Path to dump config to (default: stdout).
"""

def parse_include(self, ref_dict: Optional[dict] = None) -> None:
"""
Recursively process include directives in a config object.
Recursively traverse the dictionary, replacing include tags with the contents of the files
they specify. Assumes a section/key/value structure. YAML provides this functionality in its
own loader.
:param ref_dict: A config object to process instead of the object's own data.
"""
if ref_dict is None:
ref_dict = self.data
for key, value in deepcopy(ref_dict).items():
if isinstance(value, dict):
self.parse_include(ref_dict[key])
elif isinstance(value, str):
if m := re.match(r"^\s*%s\s+(.*)" % INCLUDE_TAG, value):
filepaths = yaml.safe_load(m[1])
self.update_from(self._load_paths(filepaths))
del ref_dict[key]

def update_from(self, src: Union[dict, Config]) -> None:
def update_from(self, src: Union[dict, UserDict]) -> None:
"""
Updates a config.
Expand All @@ -254,4 +250,4 @@ def update(src: dict, dst: dict) -> None:
else:
dst[key] = val

update(src.data if isinstance(src, Config) else src, self.data)
update(src.data if isinstance(src, UserDict) else src, self.data)
Loading

0 comments on commit a99fe8f

Please sign in to comment.