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)})"