Source code for argclass.defaults

"""Default value parsers for loading configuration from files."""

import ast
import configparser
import json
import os
from abc import ABC, abstractmethod
from enum import IntEnum
from pathlib import Path
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, Union

from .exceptions import ConfigurationError

try:
    import tomllib

    toml_load = tomllib.load
except ImportError:  # pragma: no cover
    try:
        import tomli  # type: ignore[import-not-found]

        toml_load = tomli.load  # type: ignore[assignment]
    except ImportError:
        toml_load = None  # type: ignore[assignment]


[docs] class ValueKind(IntEnum): """Expected value type for config loading.""" STRING = 0 # Default, no conversion SEQUENCE = 1 # list/tuple or something iterable BOOL = 2 # boolean value
[docs] class UnexpectedConfigValue(ConfigurationError): """Config value doesn't match expected type."""
[docs] def __init__(self, key: str, expected: ValueKind, value: Any): self.value = repr(value) self.expected = expected self.key = key # Backward compatibility alias super().__init__( f"expected {expected.name}, " f"got {type(value).__name__}: {self.value}", field_name=key, hint=f"Provide a value of type {expected.name}", )
[docs] class AbstractDefaultsParser(ABC): """Abstract base class for parsing configuration files into defaults. Subclass this to implement custom configuration file formats for the `config_files` parameter of Parser. """
[docs] def __init__( self, paths: Iterable[Union[str, Path]], strict: bool = False, ): self._paths = list(paths) self._strict = strict self._loaded_files: Tuple[Path, ...] = () self._values: Dict[str, Any] = {}
@property def loaded_files(self) -> Tuple[Path, ...]: """Return tuple of successfully loaded file paths.""" return self._loaded_files def _filter_readable_paths(self) -> list[Path]: """Filter paths to only include readable, existing files.""" result = [] for path in self._paths: path_obj = Path(path).expanduser().resolve() if os.access(path_obj, os.R_OK) and path_obj.exists(): result.append(path_obj) return result
[docs] @abstractmethod def parse(self) -> Mapping[str, Any]: """Parse configuration files and return defaults mapping. Returns: A mapping where keys are argument names or group names, and values are either default values (str) or nested mappings for groups. """ raise NotImplementedError()
[docs] def get_value( self, key: str, kind: ValueKind = ValueKind.STRING, section: Optional[str] = None, ) -> Any: """Get value with type validation. Args: key: The config key name. kind: Expected value type for validation. section: Optional section/group name for nested values. Returns: The value, converted if necessary (e.g., INI literal_eval). Raises: UnexpectedConfigValue: If value doesn't match expected kind. """ if section is not None: source = self._values.get(section, {}) if not isinstance(source, dict): return None else: source = self._values value = source.get(key) if value is None: return None # Subclass can convert (INI: literal_eval) value = self._convert(key, value, kind) # Validate if kind == ValueKind.SEQUENCE: if not isinstance(value, (list, tuple)): raise UnexpectedConfigValue(key, kind, value) elif kind == ValueKind.BOOL: if not isinstance(value, bool): raise UnexpectedConfigValue(key, kind, value) return value
def _convert(self, key: str, value: Any, kind: ValueKind) -> Any: """Override for format-specific conversion.""" return value
[docs] class INIDefaultsParser(AbstractDefaultsParser): """Parse INI configuration files for default values. This is the default parser used by argclass. It uses Python's configparser module to read INI files. INI sections map to argument groups, and the [DEFAULT] section contains top-level argument defaults. Values that look like Python literals (lists, bools) are converted when requested via get_value() with appropriate ValueKind. """ # Values considered as True for boolean conversion BOOL_TRUE_VALUES = frozenset( ( "true", "yes", "1", "on", "enable", "enabled", "t", "y", ) )
[docs] def parse(self) -> Mapping[str, Any]: parser = configparser.ConfigParser( allow_no_value=True, strict=self._strict, ) filenames = self._filter_readable_paths() loaded = parser.read(filenames) self._loaded_files = tuple(Path(f) for f in loaded) result: Dict[str, Any] = dict( parser.items(parser.default_section, raw=True), ) for section in parser.sections(): result[section] = dict(parser.items(section, raw=True)) self._values = result return result
def _convert(self, key: str, value: Any, kind: ValueKind) -> Any: """Convert INI string values based on expected kind.""" if not isinstance(value, str): return value if kind == ValueKind.SEQUENCE: try: return ast.literal_eval(value) except (ValueError, SyntaxError) as e: raise UnexpectedConfigValue(key, kind, value) from e if kind == ValueKind.BOOL: return value.lower() in self.BOOL_TRUE_VALUES return value
[docs] class JSONDefaultsParser(AbstractDefaultsParser): """Parse JSON configuration files for default values. The JSON structure should be a flat or nested object where: - Top-level keys are argument names or group names - Group values are objects with argument names as keys JSON natively supports lists and booleans, so no conversion needed. """
[docs] def parse(self) -> Mapping[str, Any]: result: Dict[str, Any] = {} loaded_files = [] for path in self._filter_readable_paths(): try: with path.open("r") as fp: data = json.load(fp) if isinstance(data, dict): result.update(data) loaded_files.append(path) except (json.JSONDecodeError, OSError): if self._strict: raise self._loaded_files = tuple(loaded_files) self._values = result return result
[docs] class TOMLDefaultsParser(AbstractDefaultsParser): """Parse TOML configuration files for default values. Uses stdlib tomllib (Python 3.11+) or tomli package as fallback. The TOML structure should be: - Top-level keys are argument names - Tables (sections) map to argument groups TOML natively supports lists and booleans, so no conversion needed. """
[docs] def parse(self) -> Mapping[str, Any]: if toml_load is None: raise RuntimeError( "TOML support requires Python 3.11+ (tomllib) " "or 'tomli' package: pip install tomli" ) result: Dict[str, Any] = {} loaded_files = [] for path in self._filter_readable_paths(): try: with path.open("rb") as fp: data = toml_load(fp) if isinstance(data, dict): result.update(data) loaded_files.append(path) except OSError: if self._strict: raise self._loaded_files = tuple(loaded_files) self._values = result return result