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