You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			350 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			350 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Python
		
	
"""
 | 
						|
Code of the config system; not related to fontTools or fonts in particular.
 | 
						|
 | 
						|
The options that are specific to fontTools are in :mod:`fontTools.config`.
 | 
						|
 | 
						|
To create your own config system, you need to create an instance of
 | 
						|
:class:`Options`, and a subclass of :class:`AbstractConfig` with its
 | 
						|
``options`` class variable set to your instance of Options.
 | 
						|
 | 
						|
"""
 | 
						|
 | 
						|
from __future__ import annotations
 | 
						|
 | 
						|
import logging
 | 
						|
from dataclasses import dataclass
 | 
						|
from typing import (
 | 
						|
    Any,
 | 
						|
    Callable,
 | 
						|
    ClassVar,
 | 
						|
    Dict,
 | 
						|
    Iterable,
 | 
						|
    Mapping,
 | 
						|
    MutableMapping,
 | 
						|
    Optional,
 | 
						|
    Set,
 | 
						|
    Union,
 | 
						|
)
 | 
						|
 | 
						|
 | 
						|
log = logging.getLogger(__name__)
 | 
						|
 | 
						|
__all__ = [
 | 
						|
    "AbstractConfig",
 | 
						|
    "ConfigAlreadyRegisteredError",
 | 
						|
    "ConfigError",
 | 
						|
    "ConfigUnknownOptionError",
 | 
						|
    "ConfigValueParsingError",
 | 
						|
    "ConfigValueValidationError",
 | 
						|
    "Option",
 | 
						|
    "Options",
 | 
						|
]
 | 
						|
 | 
						|
 | 
						|
class ConfigError(Exception):
 | 
						|
    """Base exception for the config module."""
 | 
						|
 | 
						|
 | 
						|
class ConfigAlreadyRegisteredError(ConfigError):
 | 
						|
    """Raised when a module tries to register a configuration option that
 | 
						|
    already exists.
 | 
						|
 | 
						|
    Should not be raised too much really, only when developing new fontTools
 | 
						|
    modules.
 | 
						|
    """
 | 
						|
 | 
						|
    def __init__(self, name):
 | 
						|
        super().__init__(f"Config option {name} is already registered.")
 | 
						|
 | 
						|
 | 
						|
class ConfigValueParsingError(ConfigError):
 | 
						|
    """Raised when a configuration value cannot be parsed."""
 | 
						|
 | 
						|
    def __init__(self, name, value):
 | 
						|
        super().__init__(
 | 
						|
            f"Config option {name}: value cannot be parsed (given {repr(value)})"
 | 
						|
        )
 | 
						|
 | 
						|
 | 
						|
class ConfigValueValidationError(ConfigError):
 | 
						|
    """Raised when a configuration value cannot be validated."""
 | 
						|
 | 
						|
    def __init__(self, name, value):
 | 
						|
        super().__init__(
 | 
						|
            f"Config option {name}: value is invalid (given {repr(value)})"
 | 
						|
        )
 | 
						|
 | 
						|
 | 
						|
class ConfigUnknownOptionError(ConfigError):
 | 
						|
    """Raised when a configuration option is unknown."""
 | 
						|
 | 
						|
    def __init__(self, option_or_name):
 | 
						|
        name = (
 | 
						|
            f"'{option_or_name.name}' (id={id(option_or_name)})>"
 | 
						|
            if isinstance(option_or_name, Option)
 | 
						|
            else f"'{option_or_name}'"
 | 
						|
        )
 | 
						|
        super().__init__(f"Config option {name} is unknown")
 | 
						|
 | 
						|
 | 
						|
# eq=False because Options are unique, not fungible objects
 | 
						|
@dataclass(frozen=True, eq=False)
 | 
						|
class Option:
 | 
						|
    name: str
 | 
						|
    """Unique name identifying the option (e.g. package.module:MY_OPTION)."""
 | 
						|
    help: str
 | 
						|
    """Help text for this option."""
 | 
						|
    default: Any
 | 
						|
    """Default value for this option."""
 | 
						|
    parse: Callable[[str], Any]
 | 
						|
    """Turn input (e.g. string) into proper type. Only when reading from file."""
 | 
						|
    validate: Optional[Callable[[Any], bool]] = None
 | 
						|
    """Return true if the given value is an acceptable value."""
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def parse_optional_bool(v: str) -> Optional[bool]:
 | 
						|
        s = str(v).lower()
 | 
						|
        if s in {"0", "no", "false"}:
 | 
						|
            return False
 | 
						|
        if s in {"1", "yes", "true"}:
 | 
						|
            return True
 | 
						|
        if s in {"auto", "none"}:
 | 
						|
            return None
 | 
						|
        raise ValueError("invalid optional bool: {v!r}")
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def validate_optional_bool(v: Any) -> bool:
 | 
						|
        return v is None or isinstance(v, bool)
 | 
						|
 | 
						|
 | 
						|
class Options(Mapping):
 | 
						|
    """Registry of available options for a given config system.
 | 
						|
 | 
						|
    Define new options using the :meth:`register()` method.
 | 
						|
 | 
						|
    Access existing options using the Mapping interface.
 | 
						|
    """
 | 
						|
 | 
						|
    __options: Dict[str, Option]
 | 
						|
 | 
						|
    def __init__(self, other: "Options" = None) -> None:
 | 
						|
        self.__options = {}
 | 
						|
        if other is not None:
 | 
						|
            for option in other.values():
 | 
						|
                self.register_option(option)
 | 
						|
 | 
						|
    def register(
 | 
						|
        self,
 | 
						|
        name: str,
 | 
						|
        help: str,
 | 
						|
        default: Any,
 | 
						|
        parse: Callable[[str], Any],
 | 
						|
        validate: Optional[Callable[[Any], bool]] = None,
 | 
						|
    ) -> Option:
 | 
						|
        """Create and register a new option."""
 | 
						|
        return self.register_option(Option(name, help, default, parse, validate))
 | 
						|
 | 
						|
    def register_option(self, option: Option) -> Option:
 | 
						|
        """Register a new option."""
 | 
						|
        name = option.name
 | 
						|
        if name in self.__options:
 | 
						|
            raise ConfigAlreadyRegisteredError(name)
 | 
						|
        self.__options[name] = option
 | 
						|
        return option
 | 
						|
 | 
						|
    def is_registered(self, option: Option) -> bool:
 | 
						|
        """Return True if the same option object is already registered."""
 | 
						|
        return self.__options.get(option.name) is option
 | 
						|
 | 
						|
    def __getitem__(self, key: str) -> Option:
 | 
						|
        return self.__options.__getitem__(key)
 | 
						|
 | 
						|
    def __iter__(self) -> Iterator[str]:
 | 
						|
        return self.__options.__iter__()
 | 
						|
 | 
						|
    def __len__(self) -> int:
 | 
						|
        return self.__options.__len__()
 | 
						|
 | 
						|
    def __repr__(self) -> str:
 | 
						|
        return (
 | 
						|
            f"{self.__class__.__name__}({{\n"
 | 
						|
            + "".join(
 | 
						|
                f"    {k!r}: Option(default={v.default!r}, ...),\n"
 | 
						|
                for k, v in self.__options.items()
 | 
						|
            )
 | 
						|
            + "})"
 | 
						|
        )
 | 
						|
 | 
						|
 | 
						|
_USE_GLOBAL_DEFAULT = object()
 | 
						|
 | 
						|
 | 
						|
class AbstractConfig(MutableMapping):
 | 
						|
    """
 | 
						|
    Create a set of config values, optionally pre-filled with values from
 | 
						|
    the given dictionary or pre-existing config object.
 | 
						|
 | 
						|
    The class implements the MutableMapping protocol keyed by option name (`str`).
 | 
						|
    For convenience its methods accept either Option or str as the key parameter.
 | 
						|
 | 
						|
    .. seealso:: :meth:`set()`
 | 
						|
 | 
						|
    This config class is abstract because it needs its ``options`` class
 | 
						|
    var to be set to an instance of :class:`Options` before it can be
 | 
						|
    instanciated and used.
 | 
						|
 | 
						|
    .. code:: python
 | 
						|
 | 
						|
        class MyConfig(AbstractConfig):
 | 
						|
            options = Options()
 | 
						|
 | 
						|
        MyConfig.register_option( "test:option_name", "This is an option", 0, int, lambda v: isinstance(v, int))
 | 
						|
 | 
						|
        cfg = MyConfig({"test:option_name": 10})
 | 
						|
 | 
						|
    """
 | 
						|
 | 
						|
    options: ClassVar[Options]
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def register_option(
 | 
						|
        cls,
 | 
						|
        name: str,
 | 
						|
        help: str,
 | 
						|
        default: Any,
 | 
						|
        parse: Callable[[str], Any],
 | 
						|
        validate: Optional[Callable[[Any], bool]] = None,
 | 
						|
    ) -> Option:
 | 
						|
        """Register an available option in this config system."""
 | 
						|
        return cls.options.register(
 | 
						|
            name, help=help, default=default, parse=parse, validate=validate
 | 
						|
        )
 | 
						|
 | 
						|
    _values: Dict[str, Any]
 | 
						|
 | 
						|
    def __init__(
 | 
						|
        self,
 | 
						|
        values: Union[AbstractConfig, Dict[Union[Option, str], Any]] = {},
 | 
						|
        parse_values: bool = False,
 | 
						|
        skip_unknown: bool = False,
 | 
						|
    ):
 | 
						|
        self._values = {}
 | 
						|
        values_dict = values._values if isinstance(values, AbstractConfig) else values
 | 
						|
        for name, value in values_dict.items():
 | 
						|
            self.set(name, value, parse_values, skip_unknown)
 | 
						|
 | 
						|
    def _resolve_option(self, option_or_name: Union[Option, str]) -> Option:
 | 
						|
        if isinstance(option_or_name, Option):
 | 
						|
            option = option_or_name
 | 
						|
            if not self.options.is_registered(option):
 | 
						|
                raise ConfigUnknownOptionError(option)
 | 
						|
            return option
 | 
						|
        elif isinstance(option_or_name, str):
 | 
						|
            name = option_or_name
 | 
						|
            try:
 | 
						|
                return self.options[name]
 | 
						|
            except KeyError:
 | 
						|
                raise ConfigUnknownOptionError(name)
 | 
						|
        else:
 | 
						|
            raise TypeError(
 | 
						|
                "expected Option or str, found "
 | 
						|
                f"{type(option_or_name).__name__}: {option_or_name!r}"
 | 
						|
            )
 | 
						|
 | 
						|
    def set(
 | 
						|
        self,
 | 
						|
        option_or_name: Union[Option, str],
 | 
						|
        value: Any,
 | 
						|
        parse_values: bool = False,
 | 
						|
        skip_unknown: bool = False,
 | 
						|
    ):
 | 
						|
        """Set the value of an option.
 | 
						|
 | 
						|
        Args:
 | 
						|
            * `option_or_name`: an `Option` object or its name (`str`).
 | 
						|
            * `value`: the value to be assigned to given option.
 | 
						|
            * `parse_values`: parse the configuration value from a string into
 | 
						|
                its proper type, as per its `Option` object. The default
 | 
						|
                behavior is to raise `ConfigValueValidationError` when the value
 | 
						|
                is not of the right type. Useful when reading options from a
 | 
						|
                file type that doesn't support as many types as Python.
 | 
						|
            * `skip_unknown`: skip unknown configuration options. The default
 | 
						|
                behaviour is to raise `ConfigUnknownOptionError`. Useful when
 | 
						|
                reading options from a configuration file that has extra entries
 | 
						|
                (e.g. for a later version of fontTools)
 | 
						|
        """
 | 
						|
        try:
 | 
						|
            option = self._resolve_option(option_or_name)
 | 
						|
        except ConfigUnknownOptionError as e:
 | 
						|
            if skip_unknown:
 | 
						|
                log.debug(str(e))
 | 
						|
                return
 | 
						|
            raise
 | 
						|
 | 
						|
        # Can be useful if the values come from a source that doesn't have
 | 
						|
        # strict typing (.ini file? Terminal input?)
 | 
						|
        if parse_values:
 | 
						|
            try:
 | 
						|
                value = option.parse(value)
 | 
						|
            except Exception as e:
 | 
						|
                raise ConfigValueParsingError(option.name, value) from e
 | 
						|
 | 
						|
        if option.validate is not None and not option.validate(value):
 | 
						|
            raise ConfigValueValidationError(option.name, value)
 | 
						|
 | 
						|
        self._values[option.name] = value
 | 
						|
 | 
						|
    def get(
 | 
						|
        self, option_or_name: Union[Option, str], default: Any = _USE_GLOBAL_DEFAULT
 | 
						|
    ) -> Any:
 | 
						|
        """
 | 
						|
        Get the value of an option. The value which is returned is the first
 | 
						|
        provided among:
 | 
						|
 | 
						|
        1. a user-provided value in the options's ``self._values`` dict
 | 
						|
        2. a caller-provided default value to this method call
 | 
						|
        3. the global default for the option provided in ``fontTools.config``
 | 
						|
 | 
						|
        This is to provide the ability to migrate progressively from config
 | 
						|
        options passed as arguments to fontTools APIs to config options read
 | 
						|
        from the current TTFont, e.g.
 | 
						|
 | 
						|
        .. code:: python
 | 
						|
 | 
						|
            def fontToolsAPI(font, some_option):
 | 
						|
                value = font.cfg.get("someLib.module:SOME_OPTION", some_option)
 | 
						|
                # use value
 | 
						|
 | 
						|
        That way, the function will work the same for users of the API that
 | 
						|
        still pass the option to the function call, but will favour the new
 | 
						|
        config mechanism if the given font specifies a value for that option.
 | 
						|
        """
 | 
						|
        option = self._resolve_option(option_or_name)
 | 
						|
        if option.name in self._values:
 | 
						|
            return self._values[option.name]
 | 
						|
        if default is not _USE_GLOBAL_DEFAULT:
 | 
						|
            return default
 | 
						|
        return option.default
 | 
						|
 | 
						|
    def copy(self):
 | 
						|
        return self.__class__(self._values)
 | 
						|
 | 
						|
    def __getitem__(self, option_or_name: Union[Option, str]) -> Any:
 | 
						|
        return self.get(option_or_name)
 | 
						|
 | 
						|
    def __setitem__(self, option_or_name: Union[Option, str], value: Any) -> None:
 | 
						|
        return self.set(option_or_name, value)
 | 
						|
 | 
						|
    def __delitem__(self, option_or_name: Union[Option, str]) -> None:
 | 
						|
        option = self._resolve_option(option_or_name)
 | 
						|
        del self._values[option.name]
 | 
						|
 | 
						|
    def __iter__(self) -> Iterable[str]:
 | 
						|
        return self._values.__iter__()
 | 
						|
 | 
						|
    def __len__(self) -> int:
 | 
						|
        return len(self._values)
 | 
						|
 | 
						|
    def __repr__(self) -> str:
 | 
						|
        return f"{self.__class__.__name__}({repr(self._values)})"
 |