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[
228 AbstractConfig, Dict[Union[Option, str], Any], Mapping[str, Any]
229 ] = {},
230 parse_values: bool = False,
231 skip_unknown: bool = False,
232 ):
233 self._values = {}
234 values_dict = values._values if isinstance(values, AbstractConfig) else values
235 for name, value in values_dict.items():
236 self.set(name, value, parse_values, skip_unknown)
237
238 def _resolve_option(self, option_or_name: Union[Option, str]) -> Option:
239 if isinstance(option_or_name, Option):
240 option = option_or_name
241 if not self.options.is_registered(option):
242 raise ConfigUnknownOptionError(option)
243 return option
244 elif isinstance(option_or_name, str):
245 name = option_or_name
246 try:
247 return self.options[name]
248 except KeyError:
249 raise ConfigUnknownOptionError(name)
250 else:
251 raise TypeError(
252 "expected Option or str, found "
253 f"{type(option_or_name).__name__}: {option_or_name!r}"
254 )
255
256 def set(
257 self,
258 option_or_name: Union[Option, str],
259 value: Any,
260 parse_values: bool = False,
261 skip_unknown: bool = False,
262 ):
263 """Set the value of an option.
264
265 Args:
266 * `option_or_name`: an `Option` object or its name (`str`).
267 * `value`: the value to be assigned to given option.
268 * `parse_values`: parse the configuration value from a string into
269 its proper type, as per its `Option` object. The default
270 behavior is to raise `ConfigValueValidationError` when the value
271 is not of the right type. Useful when reading options from a
272 file type that doesn't support as many types as Python.
273 * `skip_unknown`: skip unknown configuration options. The default
274 behaviour is to raise `ConfigUnknownOptionError`. Useful when
275 reading options from a configuration file that has extra entries
276 (e.g. for a later version of fontTools)
277 """
278 try:
279 option = self._resolve_option(option_or_name)
280 except ConfigUnknownOptionError as e:
281 if skip_unknown:
282 log.debug(str(e))
283 return
284 raise
285
286 # Can be useful if the values come from a source that doesn't have
287 # strict typing (.ini file? Terminal input?)
288 if parse_values:
289 try:
290 value = option.parse(value)
291 except Exception as e:
292 raise ConfigValueParsingError(option.name, value) from e
293
294 if option.validate is not None and not option.validate(value):
295 raise ConfigValueValidationError(option.name, value)
296
297 self._values[option.name] = value
298
299 def get(
300 self, option_or_name: Union[Option, str], default: Any = _USE_GLOBAL_DEFAULT
301 ) -> Any:
302 """
303 Get the value of an option. The value which is returned is the first
304 provided among:
305
306 1. a user-provided value in the options's ``self._values`` dict
307 2. a caller-provided default value to this method call
308 3. the global default for the option provided in ``fontTools.config``
309
310 This is to provide the ability to migrate progressively from config
311 options passed as arguments to fontTools APIs to config options read
312 from the current TTFont, e.g.
313
314 .. code:: python
315
316 def fontToolsAPI(font, some_option):
317 value = font.cfg.get("someLib.module:SOME_OPTION", some_option)
318 # use value
319
320 That way, the function will work the same for users of the API that
321 still pass the option to the function call, but will favour the new
322 config mechanism if the given font specifies a value for that option.
323 """
324 option = self._resolve_option(option_or_name)
325 if option.name in self._values:
326 return self._values[option.name]
327 if default is not _USE_GLOBAL_DEFAULT:
328 return default
329 return option.default
330
331 def copy(self):
332 return self.__class__(self._values)
333
334 def __getitem__(self, option_or_name: Union[Option, str]) -> Any:
335 return self.get(option_or_name)
336
337 def __setitem__(self, option_or_name: Union[Option, str], value: Any) -> None:
338 return self.set(option_or_name, value)
339
340 def __delitem__(self, option_or_name: Union[Option, str]) -> None:
341 option = self._resolve_option(option_or_name)
342 del self._values[option.name]
343
344 def __iter__(self) -> Iterable[str]:
345 return self._values.__iter__()
346
347 def __len__(self) -> int:
348 return len(self._values)
349
350 def __repr__(self) -> str:
351 return f"{self.__class__.__name__}({repr(self._values)})"