[configTools] accept either str or Option in Config mapping API

This commit is contained in:
Cosimo Lupo 2022-04-21 15:33:16 +01:00
parent 9a0dfbd403
commit e5d674ea5e
2 changed files with 130 additions and 63 deletions

View File

@ -17,9 +17,11 @@ from typing import (
Callable, Callable,
ClassVar, ClassVar,
Dict, Dict,
Iterator, Iterable,
Mapping, Mapping,
MutableMapping, MutableMapping,
Optional,
Set,
Union, Union,
) )
@ -72,28 +74,29 @@ class ConfigValueValidationError(ConfigError):
) )
_NO_VALUE = object()
class ConfigUnknownOptionError(ConfigError): class ConfigUnknownOptionError(ConfigError):
"""Raised when a configuration option is unknown.""" """Raised when a configuration option is unknown."""
def __init__(self, name, value=_NO_VALUE): def __init__(self, option_or_name):
super().__init__( name = (
f"Config option {name} is unknown" f"'{option_or_name.name}' (id={id(option_or_name)})>"
+ ("" if value is _NO_VALUE else f" (with given value {repr(value)})") if isinstance(option_or_name, Option)
else f"'{option_or_name}'"
) )
super().__init__(f"Config option {name} is unknown")
@dataclass @dataclass(frozen=True)
class Option: class Option:
name: str
"""Unique name identifying the option (e.g. package.module:MY_OPTION)."""
help: str help: str
"""Help text for this option.""" """Help text for this option."""
default: Any default: Any
"""Default value for this option.""" """Default value for this option."""
parse: Callable[[str], Any] parse: Callable[[str], Any]
"""Turn input (e.g. string) into proper type. Only when reading from file.""" """Turn input (e.g. string) into proper type. Only when reading from file."""
validate: Callable[[Any], bool] validate: Optional[Callable[[Any], bool]] = None
"""Return true if the given value is an acceptable value.""" """Return true if the given value is an acceptable value."""
@ -106,12 +109,14 @@ class Options(Mapping):
""" """
__options: Dict[str, Option] __options: Dict[str, Option]
__cache: Set[Option]
def __init__(self, other: "Options" = None) -> None: def __init__(self, other: "Options" = None) -> None:
self.__options = {} self.__options = {}
self.__cache = set()
if other is not None: if other is not None:
for name, option in other.items(): for option in other.values():
self.register_option(name, option) self.register_option(option)
def register( def register(
self, self,
@ -119,18 +124,26 @@ class Options(Mapping):
help: str, help: str,
default: Any, default: Any,
parse: Callable[[str], Any], parse: Callable[[str], Any],
validate: Callable[[Any], bool], validate: Optional[Callable[[Any], bool]] = None,
) -> Option: ) -> Option:
"""Register a new option.""" """Create and register a new option."""
return self.register_option(name, Option(help, default, parse, validate)) return self.register_option(Option(name, help, default, parse, validate))
def register_option(self, name: str, option: Option) -> Option: def register_option(self, option: Option) -> Option:
"""Register a new option.""" """Register a new option."""
name = option.name
if name in self.__options: if name in self.__options:
raise ConfigAlreadyRegisteredError(name) raise ConfigAlreadyRegisteredError(name)
# sanity check option values are unique
assert option not in self.__cache
self.__options[name] = option self.__options[name] = option
self.__cache.add(option)
return option return option
def is_registered(self, option: Option) -> bool:
"""Return True if the option object is already registered."""
return option in self.__cache
def __getitem__(self, key: str) -> Option: def __getitem__(self, key: str) -> Option:
return self.__options.__getitem__(key) return self.__options.__getitem__(key)
@ -157,7 +170,10 @@ _USE_GLOBAL_DEFAULT = object()
class AbstractConfig(MutableMapping): class AbstractConfig(MutableMapping):
""" """
Create a set of config values, optionally pre-filled with values from Create a set of config values, optionally pre-filled with values from
the given dictionary. 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()` .. seealso:: :meth:`set()`
@ -173,32 +189,68 @@ class AbstractConfig(MutableMapping):
MyConfig.register_option( "test:option_name", "This is an option", 0, int, lambda v: isinstance(v, int)) MyConfig.register_option( "test:option_name", "This is an option", 0, int, lambda v: isinstance(v, int))
cfg = MyConfig({"test:option_name": 10}) cfg = MyConfig({"test:option_name": 10})
""" """
options: ClassVar[Options] options: ClassVar[Options]
@classmethod @classmethod
def register_option(cls, *args, **kwargs) -> Option: 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.""" """Register an available option in this config system."""
return cls.options.register(*args, **kwargs) return cls.options.register(
name, help=help, default=default, parse=parse, validate=validate
)
_values: Dict[str, Any] _values: Dict[str, Any]
def __init__( def __init__(
self, self,
values: Union[AbstractConfig, Dict] = {}, values: Union[AbstractConfig, Dict[Union[Option, str], Any]] = {},
parse_values=False, parse_values: bool = False,
skip_unknown=False, skip_unknown: bool = False,
): ):
self._values = {} self._values = {}
values_dict = values._values if isinstance(values, AbstractConfig) else values values_dict = values._values if isinstance(values, AbstractConfig) else values
for name, value in values_dict.items(): for name, value in values_dict.items():
self.set(name, value, parse_values, skip_unknown) self.set(name, value, parse_values, skip_unknown)
def set(self, name: str, value: Any, parse_values=False, skip_unknown=False): 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. """Set the value of an option.
Args: 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 * `parse_values`: parse the configuration value from a string into
its proper type, as per its `Option` object. The default its proper type, as per its `Option` object. The default
behavior is to raise `ConfigValueValidationError` when the value behavior is to raise `ConfigValueValidationError` when the value
@ -210,15 +262,12 @@ class AbstractConfig(MutableMapping):
(e.g. for a later version of fontTools) (e.g. for a later version of fontTools)
""" """
try: try:
option = self.options[name] option = self._resolve_option(option_or_name)
except KeyError: except ConfigUnknownOptionError as e:
if skip_unknown: if skip_unknown:
log.debug( log.debug(str(e))
"Config option %s is unknown (with given value %r)", name, value
)
return return
else: raise
raise ConfigUnknownOptionError(name, value)
# Can be useful if the values come from a source that doesn't have # Can be useful if the values come from a source that doesn't have
# strict typing (.ini file? Terminal input?) # strict typing (.ini file? Terminal input?)
@ -226,14 +275,16 @@ class AbstractConfig(MutableMapping):
try: try:
value = option.parse(value) value = option.parse(value)
except Exception as e: except Exception as e:
raise ConfigValueParsingError(name, value) from e raise ConfigValueParsingError(option.name, value) from e
if not option.validate(value): if option.validate is not None and not option.validate(value):
raise ConfigValueValidationError(name, value) raise ConfigValueValidationError(option.name, value)
self._values[name] = value self._values[option.name] = value
def get(self, name: str, default=_USE_GLOBAL_DEFAULT): 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 Get the value of an option. The value which is returned is the first
provided among: provided among:
@ -256,28 +307,27 @@ class AbstractConfig(MutableMapping):
still pass the option to the function call, but will favour the new 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. config mechanism if the given font specifies a value for that option.
""" """
if name in self._values: option = self._resolve_option(option_or_name)
return self._values[name] if option.name in self._values:
return self._values[option.name]
if default is not _USE_GLOBAL_DEFAULT: if default is not _USE_GLOBAL_DEFAULT:
return default return default
try: return option.default
return self.options[name].default
except KeyError as e:
raise ConfigUnknownOptionError(name) from e
def copy(self): def copy(self):
return self.__class__(self._values) return self.__class__(self._values)
def __getitem__(self, name: str) -> Any: def __getitem__(self, option_or_name: Union[Option, str]) -> Any:
return self.get(name) return self.get(option_or_name)
def __setitem__(self, name: str, value: Any) -> None: def __setitem__(self, option_or_name: Union[Option, str], value: Any) -> None:
return self.set(name, value) return self.set(option_or_name, value)
def __delitem__(self, name: str) -> None: def __delitem__(self, option_or_name: Union[Option, str]) -> None:
del self._values[name] option = self._resolve_option(option_or_name)
del self._values[option.name]
def __iter__(self) -> Iterator: def __iter__(self) -> Iterable[str]:
return self._values.__iter__() return self._values.__iter__()
def __len__(self) -> int: def __len__(self) -> int:

View File

@ -1,6 +1,7 @@
from fontTools.misc.configTools import AbstractConfig, Options from fontTools.misc.configTools import AbstractConfig, Options
import pytest import pytest
from fontTools.config import ( from fontTools.config import (
OPTIONS,
Config, Config,
ConfigUnknownOptionError, ConfigUnknownOptionError,
ConfigValueParsingError, ConfigValueParsingError,
@ -18,6 +19,7 @@ def test_can_register_option():
validate=lambda v: v == True or v == False, validate=lambda v: v == True or v == False,
) )
assert MY_OPTION.name == "tests:MY_OPTION"
assert ( assert (
MY_OPTION.help == "Test option, value should be True or False, default = True" MY_OPTION.help == "Test option, value should be True or False, default = True"
) )
@ -27,36 +29,51 @@ def test_can_register_option():
ttFont = TTFont(cfg={"tests:MY_OPTION": True}) ttFont = TTFont(cfg={"tests:MY_OPTION": True})
assert True == ttFont.cfg.get("tests:MY_OPTION") assert True == ttFont.cfg.get("tests:MY_OPTION")
assert True == ttFont.cfg.get(MY_OPTION)
COMPRESSION_LEVEL = "fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL" # to parametrize tests of Config mapping interface accepting either a str or Option
@pytest.fixture(
params=[
pytest.param("fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL", id="str"),
pytest.param(
OPTIONS["fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL"], id="Option"
),
]
)
def COMPRESSION_LEVEL(request):
return request.param
def test_ttfont_has_config(): def test_ttfont_has_config(COMPRESSION_LEVEL):
ttFont = TTFont(cfg={COMPRESSION_LEVEL: 8}) ttFont = TTFont(cfg={COMPRESSION_LEVEL: 8})
assert 8 == ttFont.cfg.get(COMPRESSION_LEVEL) assert 8 == ttFont.cfg.get(COMPRESSION_LEVEL)
def test_ttfont_can_take_superset_of_fonttools_config(): def test_ttfont_can_take_superset_of_fonttools_config(COMPRESSION_LEVEL):
# Create MyConfig with all options from fontTools.config plus some # Create MyConfig with all options from fontTools.config plus some
my_options = Options(Config.options) my_options = Options(Config.options)
my_options.register("custom:my_option", "help", "default", str, any) MY_OPTION = my_options.register("custom:my_option", "help", "default", str, any)
class MyConfig(AbstractConfig): class MyConfig(AbstractConfig):
options = my_options options = my_options
ttFont = TTFont(cfg=MyConfig({"custom:my_option": "my_value"})) ttFont = TTFont(cfg=MyConfig({"custom:my_option": "my_value"}))
assert 0 == ttFont.cfg.get(COMPRESSION_LEVEL) assert 0 == ttFont.cfg.get(COMPRESSION_LEVEL)
assert "my_value" == ttFont.cfg.get("custom:my_option") assert "my_value" == ttFont.cfg.get(MY_OPTION)
# but the default Config doens't know about MY_OPTION
with pytest.raises(ConfigUnknownOptionError):
TTFont(cfg={MY_OPTION: "my_value"})
def test_no_config_returns_default_values(): def test_no_config_returns_default_values(COMPRESSION_LEVEL):
ttFont = TTFont() ttFont = TTFont()
assert 0 == ttFont.cfg.get(COMPRESSION_LEVEL) assert 0 == ttFont.cfg.get(COMPRESSION_LEVEL)
assert 3 == ttFont.cfg.get(COMPRESSION_LEVEL, 3) assert 3 == ttFont.cfg.get(COMPRESSION_LEVEL, 3)
def test_can_set_config(): def test_can_set_config(COMPRESSION_LEVEL):
ttFont = TTFont() ttFont = TTFont()
ttFont.cfg.set(COMPRESSION_LEVEL, 5) ttFont.cfg.set(COMPRESSION_LEVEL, 5)
assert 5 == ttFont.cfg.get(COMPRESSION_LEVEL) assert 5 == ttFont.cfg.get(COMPRESSION_LEVEL)
@ -64,7 +81,7 @@ def test_can_set_config():
assert 6 == ttFont.cfg.get(COMPRESSION_LEVEL) assert 6 == ttFont.cfg.get(COMPRESSION_LEVEL)
def test_different_ttfonts_have_different_configs(): def test_different_ttfonts_have_different_configs(COMPRESSION_LEVEL):
cfg = Config({COMPRESSION_LEVEL: 5}) cfg = Config({COMPRESSION_LEVEL: 5})
ttFont1 = TTFont(cfg=cfg) ttFont1 = TTFont(cfg=cfg)
ttFont2 = TTFont(cfg=cfg) ttFont2 = TTFont(cfg=cfg)
@ -78,19 +95,19 @@ def test_cannot_set_inexistent_key():
TTFont(cfg={"notALib.notAModule.inexistent": 4}) TTFont(cfg={"notALib.notAModule.inexistent": 4})
def test_value_not_parsed_by_default(): def test_value_not_parsed_by_default(COMPRESSION_LEVEL):
# Note: value given as a string # Note: value given as a string
with pytest.raises(ConfigValueValidationError): with pytest.raises(ConfigValueValidationError):
TTFont(cfg={COMPRESSION_LEVEL: "8"}) TTFont(cfg={COMPRESSION_LEVEL: "8"})
def test_value_gets_parsed_if_asked(): def test_value_gets_parsed_if_asked(COMPRESSION_LEVEL):
# Note: value given as a string # Note: value given as a string
ttFont = TTFont(cfg=Config({COMPRESSION_LEVEL: "8"}, parse_values=True)) ttFont = TTFont(cfg=Config({COMPRESSION_LEVEL: "8"}, parse_values=True))
assert 8 == ttFont.cfg.get(COMPRESSION_LEVEL) assert 8 == ttFont.cfg.get(COMPRESSION_LEVEL)
def test_value_parsing_can_error(): def test_value_parsing_can_error(COMPRESSION_LEVEL):
with pytest.raises(ConfigValueParsingError): with pytest.raises(ConfigValueParsingError):
TTFont( TTFont(
cfg=Config( cfg=Config(
@ -100,19 +117,19 @@ def test_value_parsing_can_error():
) )
def test_value_gets_validated(): def test_value_gets_validated(COMPRESSION_LEVEL):
# Note: 12 is not a valid value for GPOS compression level (must be in 0-9) # Note: 12 is not a valid value for GPOS compression level (must be in 0-9)
with pytest.raises(ConfigValueValidationError): with pytest.raises(ConfigValueValidationError):
TTFont(cfg={COMPRESSION_LEVEL: 12}) TTFont(cfg={COMPRESSION_LEVEL: 12})
def test_implements_mutable_mapping(): def test_implements_mutable_mapping(COMPRESSION_LEVEL):
cfg = Config() cfg = Config()
cfg[COMPRESSION_LEVEL] = 2 cfg[COMPRESSION_LEVEL] = 2
assert 2 == cfg[COMPRESSION_LEVEL] assert 2 == cfg[COMPRESSION_LEVEL]
assert [COMPRESSION_LEVEL] == list(iter(cfg)) assert list(iter(cfg))
assert 1 == len(cfg) assert 1 == len(cfg)
del cfg[COMPRESSION_LEVEL] del cfg[COMPRESSION_LEVEL]
assert 0 == cfg[COMPRESSION_LEVEL] assert 0 == cfg[COMPRESSION_LEVEL]
assert [] == list(iter(cfg)) assert not list(iter(cfg))
assert 0 == len(cfg) assert 0 == len(cfg)