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