xref: /aosp_15_r20/external/fonttools/Lib/fontTools/misc/configTools.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""
2Code of the config system; not related to fontTools or fonts in particular.
3
4The options that are specific to fontTools are in :mod:`fontTools.config`.
5
6To create your own config system, you need to create an instance of
7:class:`Options`, and a subclass of :class:`AbstractConfig` with its
8``options`` class variable set to your instance of Options.
9
10"""
11
12from __future__ import annotations
13
14import logging
15from dataclasses import dataclass
16from typing import (
17    Any,
18    Callable,
19    ClassVar,
20    Dict,
21    Iterable,
22    Mapping,
23    MutableMapping,
24    Optional,
25    Set,
26    Union,
27)
28
29
30log = logging.getLogger(__name__)
31
32__all__ = [
33    "AbstractConfig",
34    "ConfigAlreadyRegisteredError",
35    "ConfigError",
36    "ConfigUnknownOptionError",
37    "ConfigValueParsingError",
38    "ConfigValueValidationError",
39    "Option",
40    "Options",
41]
42
43
44class ConfigError(Exception):
45    """Base exception for the config module."""
46
47
48class ConfigAlreadyRegisteredError(ConfigError):
49    """Raised when a module tries to register a configuration option that
50    already exists.
51
52    Should not be raised too much really, only when developing new fontTools
53    modules.
54    """
55
56    def __init__(self, name):
57        super().__init__(f"Config option {name} is already registered.")
58
59
60class ConfigValueParsingError(ConfigError):
61    """Raised when a configuration value cannot be parsed."""
62
63    def __init__(self, name, value):
64        super().__init__(
65            f"Config option {name}: value cannot be parsed (given {repr(value)})"
66        )
67
68
69class ConfigValueValidationError(ConfigError):
70    """Raised when a configuration value cannot be validated."""
71
72    def __init__(self, name, value):
73        super().__init__(
74            f"Config option {name}: value is invalid (given {repr(value)})"
75        )
76
77
78class ConfigUnknownOptionError(ConfigError):
79    """Raised when a configuration option is unknown."""
80
81    def __init__(self, option_or_name):
82        name = (
83            f"'{option_or_name.name}' (id={id(option_or_name)})>"
84            if isinstance(option_or_name, Option)
85            else f"'{option_or_name}'"
86        )
87        super().__init__(f"Config option {name} is unknown")
88
89
90# eq=False because Options are unique, not fungible objects
91@dataclass(frozen=True, eq=False)
92class Option:
93    name: str
94    """Unique name identifying the option (e.g. package.module:MY_OPTION)."""
95    help: str
96    """Help text for this option."""
97    default: Any
98    """Default value for this option."""
99    parse: Callable[[str], Any]
100    """Turn input (e.g. string) into proper type. Only when reading from file."""
101    validate: Optional[Callable[[Any], bool]] = None
102    """Return true if the given value is an acceptable value."""
103
104    @staticmethod
105    def parse_optional_bool(v: str) -> Optional[bool]:
106        s = str(v).lower()
107        if s in {"0", "no", "false"}:
108            return False
109        if s in {"1", "yes", "true"}:
110            return True
111        if s in {"auto", "none"}:
112            return None
113        raise ValueError("invalid optional bool: {v!r}")
114
115    @staticmethod
116    def validate_optional_bool(v: Any) -> bool:
117        return v is None or isinstance(v, bool)
118
119
120class Options(Mapping):
121    """Registry of available options for a given config system.
122
123    Define new options using the :meth:`register()` method.
124
125    Access existing options using the Mapping interface.
126    """
127
128    __options: Dict[str, Option]
129
130    def __init__(self, other: "Options" = None) -> None:
131        self.__options = {}
132        if other is not None:
133            for option in other.values():
134                self.register_option(option)
135
136    def register(
137        self,
138        name: str,
139        help: str,
140        default: Any,
141        parse: Callable[[str], Any],
142        validate: Optional[Callable[[Any], bool]] = None,
143    ) -> Option:
144        """Create and register a new option."""
145        return self.register_option(Option(name, help, default, parse, validate))
146
147    def register_option(self, option: Option) -> Option:
148        """Register a new option."""
149        name = option.name
150        if name in self.__options:
151            raise ConfigAlreadyRegisteredError(name)
152        self.__options[name] = option
153        return option
154
155    def is_registered(self, option: Option) -> bool:
156        """Return True if the same option object is already registered."""
157        return self.__options.get(option.name) is option
158
159    def __getitem__(self, key: str) -> Option:
160        return self.__options.__getitem__(key)
161
162    def __iter__(self) -> Iterator[str]:
163        return self.__options.__iter__()
164
165    def __len__(self) -> int:
166        return self.__options.__len__()
167
168    def __repr__(self) -> str:
169        return (
170            f"{self.__class__.__name__}({{\n"
171            + "".join(
172                f"    {k!r}: Option(default={v.default!r}, ...),\n"
173                for k, v in self.__options.items()
174            )
175            + "})"
176        )
177
178
179_USE_GLOBAL_DEFAULT = object()
180
181
182class AbstractConfig(MutableMapping):
183    """
184    Create a set of config values, optionally pre-filled with values from
185    the given dictionary or pre-existing config object.
186
187    The class implements the MutableMapping protocol keyed by option name (`str`).
188    For convenience its methods accept either Option or str as the key parameter.
189
190    .. seealso:: :meth:`set()`
191
192    This config class is abstract because it needs its ``options`` class
193    var to be set to an instance of :class:`Options` before it can be
194    instanciated and used.
195
196    .. code:: python
197
198        class MyConfig(AbstractConfig):
199            options = Options()
200
201        MyConfig.register_option( "test:option_name", "This is an option", 0, int, lambda v: isinstance(v, int))
202
203        cfg = MyConfig({"test:option_name": 10})
204
205    """
206
207    options: ClassVar[Options]
208
209    @classmethod
210    def register_option(
211        cls,
212        name: str,
213        help: str,
214        default: Any,
215        parse: Callable[[str], Any],
216        validate: Optional[Callable[[Any], bool]] = None,
217    ) -> Option:
218        """Register an available option in this config system."""
219        return cls.options.register(
220            name, help=help, default=default, parse=parse, validate=validate
221        )
222
223    _values: Dict[str, Any]
224
225    def __init__(
226        self,
227        values: Union[AbstractConfig, Dict[Union[Option, str], Any]] = {},
228        parse_values: bool = False,
229        skip_unknown: bool = False,
230    ):
231        self._values = {}
232        values_dict = values._values if isinstance(values, AbstractConfig) else values
233        for name, value in values_dict.items():
234            self.set(name, value, parse_values, skip_unknown)
235
236    def _resolve_option(self, option_or_name: Union[Option, str]) -> Option:
237        if isinstance(option_or_name, Option):
238            option = option_or_name
239            if not self.options.is_registered(option):
240                raise ConfigUnknownOptionError(option)
241            return option
242        elif isinstance(option_or_name, str):
243            name = option_or_name
244            try:
245                return self.options[name]
246            except KeyError:
247                raise ConfigUnknownOptionError(name)
248        else:
249            raise TypeError(
250                "expected Option or str, found "
251                f"{type(option_or_name).__name__}: {option_or_name!r}"
252            )
253
254    def set(
255        self,
256        option_or_name: Union[Option, str],
257        value: Any,
258        parse_values: bool = False,
259        skip_unknown: bool = False,
260    ):
261        """Set the value of an option.
262
263        Args:
264            * `option_or_name`: an `Option` object or its name (`str`).
265            * `value`: the value to be assigned to given option.
266            * `parse_values`: parse the configuration value from a string into
267                its proper type, as per its `Option` object. The default
268                behavior is to raise `ConfigValueValidationError` when the value
269                is not of the right type. Useful when reading options from a
270                file type that doesn't support as many types as Python.
271            * `skip_unknown`: skip unknown configuration options. The default
272                behaviour is to raise `ConfigUnknownOptionError`. Useful when
273                reading options from a configuration file that has extra entries
274                (e.g. for a later version of fontTools)
275        """
276        try:
277            option = self._resolve_option(option_or_name)
278        except ConfigUnknownOptionError as e:
279            if skip_unknown:
280                log.debug(str(e))
281                return
282            raise
283
284        # Can be useful if the values come from a source that doesn't have
285        # strict typing (.ini file? Terminal input?)
286        if parse_values:
287            try:
288                value = option.parse(value)
289            except Exception as e:
290                raise ConfigValueParsingError(option.name, value) from e
291
292        if option.validate is not None and not option.validate(value):
293            raise ConfigValueValidationError(option.name, value)
294
295        self._values[option.name] = value
296
297    def get(
298        self, option_or_name: Union[Option, str], default: Any = _USE_GLOBAL_DEFAULT
299    ) -> Any:
300        """
301        Get the value of an option. The value which is returned is the first
302        provided among:
303
304        1. a user-provided value in the options's ``self._values`` dict
305        2. a caller-provided default value to this method call
306        3. the global default for the option provided in ``fontTools.config``
307
308        This is to provide the ability to migrate progressively from config
309        options passed as arguments to fontTools APIs to config options read
310        from the current TTFont, e.g.
311
312        .. code:: python
313
314            def fontToolsAPI(font, some_option):
315                value = font.cfg.get("someLib.module:SOME_OPTION", some_option)
316                # use value
317
318        That way, the function will work the same for users of the API that
319        still pass the option to the function call, but will favour the new
320        config mechanism if the given font specifies a value for that option.
321        """
322        option = self._resolve_option(option_or_name)
323        if option.name in self._values:
324            return self._values[option.name]
325        if default is not _USE_GLOBAL_DEFAULT:
326            return default
327        return option.default
328
329    def copy(self):
330        return self.__class__(self._values)
331
332    def __getitem__(self, option_or_name: Union[Option, str]) -> Any:
333        return self.get(option_or_name)
334
335    def __setitem__(self, option_or_name: Union[Option, str], value: Any) -> None:
336        return self.set(option_or_name, value)
337
338    def __delitem__(self, option_or_name: Union[Option, str]) -> None:
339        option = self._resolve_option(option_or_name)
340        del self._values[option.name]
341
342    def __iter__(self) -> Iterable[str]:
343        return self._values.__iter__()
344
345    def __len__(self) -> int:
346        return len(self._values)
347
348    def __repr__(self) -> str:
349        return f"{self.__class__.__name__}({repr(self._values)})"
350