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