1import configparser
2import dataclasses
3from dataclasses import dataclass
4from pathlib import Path
5from typing import Callable
6from typing import ClassVar
7from typing import Optional
8from typing import Union
9
10from .helpers import make_path
11
12
13class ConfigError(BaseException):
14    pass
15
16
17class MissingConfig(ConfigError):
18    pass
19
20
21class MissingConfigSection(ConfigError):
22    pass
23
24
25class MissingConfigItem(ConfigError):
26    pass
27
28
29class ConfigValueTypeError(ConfigError):
30    pass
31
32
33class _GetterDispatch:
34    def __init__(self, initialdata, default_getter: Callable):
35        self.default_getter = default_getter
36        self.data = initialdata
37
38    def get_fn_for_type(self, type_):
39        return self.data.get(type_, self.default_getter)
40
41    def get_typed_value(self, type_, name):
42        get_fn = self.get_fn_for_type(type_)
43        return get_fn(name)
44
45
46def _parse_cfg_file(filespec: Union[Path, str]):
47    cfg = configparser.ConfigParser()
48    try:
49        filepath = make_path(filespec, check_exists=True)
50    except FileNotFoundError as e:
51        raise MissingConfig(f"No config file found at {filespec}") from e
52    else:
53        with open(filepath, encoding="utf-8") as f:
54            cfg.read_file(f)
55        return cfg
56
57
58def _build_getter(cfg_obj, cfg_section, method, converter=None):
59    def caller(option, **kwargs):
60        try:
61            rv = getattr(cfg_obj, method)(cfg_section, option, **kwargs)
62        except configparser.NoSectionError as nse:
63            raise MissingConfigSection(
64                f"No config section named {cfg_section}"
65            ) from nse
66        except configparser.NoOptionError as noe:
67            raise MissingConfigItem(f"No config item for {option}") from noe
68        except ValueError as ve:
69            # ConfigParser.getboolean, .getint, .getfloat raise ValueError
70            # on bad types
71            raise ConfigValueTypeError(
72                f"Wrong value type for {option}"
73            ) from ve
74        else:
75            if converter:
76                try:
77                    rv = converter(rv)
78                except Exception as e:
79                    raise ConfigValueTypeError(
80                        f"Wrong value type for {option}"
81                    ) from e
82            return rv
83
84    return caller
85
86
87def _build_getter_dispatch(cfg_obj, cfg_section, converters=None):
88    converters = converters or {}
89
90    default_getter = _build_getter(cfg_obj, cfg_section, "get")
91
92    # support ConfigParser builtins
93    getters = {
94        int: _build_getter(cfg_obj, cfg_section, "getint"),
95        bool: _build_getter(cfg_obj, cfg_section, "getboolean"),
96        float: _build_getter(cfg_obj, cfg_section, "getfloat"),
97        str: default_getter,
98    }
99
100    # use ConfigParser.get and convert value
101    getters.update(
102        {
103            type_: _build_getter(
104                cfg_obj, cfg_section, "get", converter=converter_fn
105            )
106            for type_, converter_fn in converters.items()
107        }
108    )
109
110    return _GetterDispatch(getters, default_getter)
111
112
113@dataclass
114class ReadsCfg:
115    section_header: ClassVar[str]
116    converters: ClassVar[Optional[dict]] = None
117
118    @classmethod
119    def from_cfg_file(cls, filespec: Union[Path, str]):
120        cfg = _parse_cfg_file(filespec)
121        dispatch = _build_getter_dispatch(
122            cfg, cls.section_header, converters=cls.converters
123        )
124        kwargs = {
125            field.name: dispatch.get_typed_value(field.type, field.name)
126            for field in dataclasses.fields(cls)
127        }
128        return cls(**kwargs)
129