# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: # Copyright 2014-2021 Florian Bruhin (The Compiler) # # This file is part of qutebrowser. # # qutebrowser is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # qutebrowser is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . """Types for options in qutebrowser's configuration. Those types are used in configdata.yml as type of a setting. Most of them are pretty generic, but some of them are e.g. specific String subclasses with valid_values set, as that particular "type" is used multiple times in the config. A setting value can be represented in three different ways: 1) As an object which can be represented in YAML: str, list, dict, int, float, True/False/None This is what qutebrowser actually saves internally, and also what it gets from the YAML or config.py. 2) As a string. This is e.g. used by the :set command. 3) As the value the code which uses it expects, e.g. enum members. Config types can do different conversations: - Object to string with .to_str() (1 -> 2) - String to object with .from_str() (2 -> 1) - Object to code with .to_py() (1 -> 3) This also validates whether the object is actually correct (type/value). """ import re import html import codecs import os.path import itertools import functools import operator import json import dataclasses from typing import (Any, Callable, Dict as DictType, Iterable, Iterator, List as ListType, Optional, Pattern, Sequence, Tuple, Union) import yaml from PyQt5.QtCore import QUrl, Qt from PyQt5.QtGui import QColor from PyQt5.QtWidgets import QTabWidget, QTabBar from PyQt5.QtNetwork import QNetworkProxy from qutebrowser.misc import objects, debugcachestats from qutebrowser.config import configexc, configutils from qutebrowser.utils import (standarddir, utils, qtutils, urlutils, urlmatch, usertypes, log) from qutebrowser.keyinput import keyutils from qutebrowser.browser.network import pac class _SystemProxy: pass SYSTEM_PROXY = _SystemProxy() # Return value for Proxy type # Taken from configparser BOOLEAN_STATES = {'1': True, 'yes': True, 'true': True, 'on': True, '0': False, 'no': False, 'false': False, 'off': False} _Completions = Optional[Iterable[Tuple[str, str]]] _StrUnset = Union[str, usertypes.Unset] _UnsetNone = Union[None, usertypes.Unset] _StrUnsetNone = Union[str, _UnsetNone] class ValidValues: """Container for valid values for a given type. Attributes: values: A list with the allowed untransformed values. descriptions: A dict with value/desc mappings. generate_docs: Whether to show the values in the docs. """ def __init__( self, *values: Union[ str, DictType[str, Optional[str]], Tuple[str, Optional[str]], ], generate_docs: bool = True, ) -> None: if not values: raise ValueError("ValidValues with no values makes no sense!") self.descriptions: DictType[str, str] = {} self.values: ListType[str] = [] self.generate_docs = generate_docs for value in values: if isinstance(value, str): # Value without description val = value desc = None elif isinstance(value, dict): # List of dicts from configdata.yml assert len(value) == 1, value val, desc = list(value.items())[0] else: val, desc = value self.values.append(val) if desc is not None: self.descriptions[val] = desc def __contains__(self, val: str) -> bool: return val in self.values def __iter__(self) -> Iterator[str]: return self.values.__iter__() def __repr__(self) -> str: return utils.get_repr(self, values=self.values, descriptions=self.descriptions) def __eq__(self, other: object) -> bool: assert isinstance(other, ValidValues) return (self.values == other.values and self.descriptions == other.descriptions) class BaseType: """A type used for a setting value. Attributes: none_ok: Whether to allow None (or an empty string for :set) as value. _completions: Override for completions for the given setting. Class attributes: valid_values: Possible values if they can be expressed as a fixed string. ValidValues instance. """ def __init__( self, *, none_ok: bool = False, completions: _Completions = None, ) -> None: self._completions = completions self.none_ok = none_ok self.valid_values: Optional[ValidValues] = None def get_name(self) -> str: """Get a name for the type for documentation.""" return self.__class__.__name__ def get_valid_values(self) -> Optional[ValidValues]: """Get the type's valid values for documentation.""" return self.valid_values def _basic_py_validation( self, value: Any, pytype: Union[type, Tuple[type, ...]]) -> None: """Do some basic validation for Python values (emptyness, type). Arguments: value: The value to check. pytype: A Python type to check the value against. """ if isinstance(value, usertypes.Unset): return if (value is None or (pytype == list and value == []) or (pytype == dict and value == {})): if not self.none_ok: raise configexc.ValidationError(value, "may not be null!") return if (not isinstance(value, pytype) or pytype is int and isinstance(value, bool)): if isinstance(pytype, tuple): expected = ' or '.join(typ.__name__ for typ in pytype) else: expected = pytype.__name__ raise configexc.ValidationError( value, "expected a value of type {} but got {}.".format( expected, type(value).__name__)) if isinstance(value, str): self._basic_str_validation(value) def _basic_str_validation(self, value: str) -> None: """Do some basic validation for string values. This checks that the value isn't empty and doesn't contain any unprintable chars. Arguments: value: The value to check. """ assert isinstance(value, str), value if not value and not self.none_ok: raise configexc.ValidationError(value, "may not be empty!") BaseType._basic_str_validation_cache(value) @staticmethod @debugcachestats.register(name='str validation cache') @functools.lru_cache(maxsize=2**9) def _basic_str_validation_cache(value: str) -> None: """Cache validation result to prevent looping over strings.""" if any(ord(c) < 32 or ord(c) == 0x7f for c in value): raise configexc.ValidationError( value, "may not contain unprintable chars!") def _validate_surrogate_escapes(self, full_value: Any, value: Any) -> None: """Make sure the given value doesn't contain surrogate escapes. This is used for values passed to json.dump, as it can't handle those. """ if not isinstance(value, str): return if any(ord(c) > 0xFFFF for c in value): raise configexc.ValidationError( full_value, "may not contain surrogate escapes!") def _validate_valid_values(self, value: str) -> None: """Validate value against possible values. The default implementation checks the value against self.valid_values if it was defined. Args: value: The value to validate. """ if self.valid_values is not None: if value not in self.valid_values: raise configexc.ValidationError( value, "valid values: {}".format(', '.join(self.valid_values))) def from_str(self, value: str) -> Any: """Get the setting value from a string. By default this invokes to_py() for validation and returns the unaltered value. This means that if to_py() returns a string rather than something more sophisticated, this doesn't need to be implemented. Args: value: The original string value. Return: The transformed value. """ self._basic_str_validation(value) self.to_py(value) # for validation if not value: return None return value def from_obj(self, value: Any) -> Any: """Get the setting value from a config.py/YAML object.""" return value def to_py(self, value: Any) -> Any: """Get the setting value from a Python value. Args: value: The value we got from Python/YAML. Return: The transformed value. Raise: configexc.ValidationError if the value was invalid. """ raise NotImplementedError def to_str(self, value: Any) -> str: """Get a string from the setting value. The resulting string should be parseable again by from_str. """ if value is None: return '' assert isinstance(value, str), value return value def to_doc(self, value: Any, indent: int = 0) -> str: """Get a string with the given value for the documentation. This currently uses asciidoc syntax. """ utils.unused(indent) # only needed for Dict/List str_value = self.to_str(value) if not str_value: return 'empty' return '+pass:[{}]+'.format(html.escape(str_value).replace(']', '\\]')) def complete(self) -> _Completions: """Return a list of possible values for completion. The default implementation just returns valid_values, but it might be useful to override this for special cases. Return: A list of (value, description) tuples or None. """ if self._completions is not None: return self._completions elif self.valid_values is None: return None return [ (val, self.valid_values.descriptions.get(val, "")) for val in self.valid_values ] def __repr__(self) -> str: return utils.get_repr(self, none_ok=self.none_ok, completions=self._completions) class MappingType(BaseType): """Base class for any setting which has a mapping to the given values. Attributes: MAPPING: A mapping from config values to (translated_value, docs) tuples. """ MAPPING: DictType[str, Tuple[Any, Optional[str]]] = {} def __init__( self, *, none_ok: bool = False, completions: _Completions = None, ) -> None: super().__init__(none_ok=none_ok, completions=completions) self.valid_values = ValidValues( *[(key, doc) for (key, (_val, doc)) in self.MAPPING.items()]) def to_py(self, value: Any) -> Any: self._basic_py_validation(value, str) if isinstance(value, usertypes.Unset): return value elif not value: return None self._validate_valid_values(value.lower()) mapped, _doc = self.MAPPING[value.lower()] return mapped def __repr__(self) -> str: return utils.get_repr(self, none_ok=self.none_ok, valid_values=self.valid_values) class String(BaseType): """A string value. See the setting's valid values for more information on allowed values. Attributes: minlen: Minimum length (inclusive). maxlen: Maximum length (inclusive). forbidden: Forbidden chars in the string. regex: A regex used to validate the string. completions: completions to be used, or None """ def __init__( self, *, minlen: int = None, maxlen: int = None, forbidden: str = None, regex: str = None, encoding: str = None, none_ok: bool = False, completions: _Completions = None, valid_values: ValidValues = None, ) -> None: super().__init__(none_ok=none_ok, completions=completions) self.valid_values = valid_values if minlen is not None and minlen < 1: raise ValueError("minlen ({}) needs to be >= 1!".format(minlen)) if maxlen is not None and maxlen < 1: raise ValueError("maxlen ({}) needs to be >= 1!".format(maxlen)) if maxlen is not None and minlen is not None and maxlen < minlen: raise ValueError("minlen ({}) needs to be <= maxlen ({})!".format( minlen, maxlen)) self.minlen = minlen self.maxlen = maxlen self.forbidden = forbidden self.encoding = encoding self.regex = regex def _validate_encoding(self, value: str) -> None: """Check if the given value fits into the configured encoding. Raises ValidationError if not. Args: value: The value to check. """ if self.encoding is None: return try: value.encode(self.encoding) except UnicodeEncodeError as e: msg = "{!r} contains non-{} characters: {}".format( value, self.encoding, e) raise configexc.ValidationError(value, msg) def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) if isinstance(value, usertypes.Unset): return value elif not value: return None self._validate_encoding(value) self._validate_valid_values(value) if self.forbidden is not None and any(c in value for c in self.forbidden): raise configexc.ValidationError(value, "may not contain the chars " "'{}'".format(self.forbidden)) if self.minlen is not None and len(value) < self.minlen: raise configexc.ValidationError(value, "must be at least {} chars " "long!".format(self.minlen)) if self.maxlen is not None and len(value) > self.maxlen: raise configexc.ValidationError(value, "must be at most {} chars " "long!".format(self.maxlen)) if self.regex is not None and not re.fullmatch(self.regex, value): raise configexc.ValidationError(value, "does not match {}" .format(self.regex)) return value def __repr__(self) -> str: return utils.get_repr(self, none_ok=self.none_ok, valid_values=self.valid_values, minlen=self.minlen, maxlen=self.maxlen, forbidden=self.forbidden, regex=self.regex, completions=self._completions, encoding=self.encoding) class UniqueCharString(String): """A string which may not contain duplicate chars.""" def to_py(self, value: _StrUnset) -> _StrUnsetNone: py_value = super().to_py(value) if isinstance(py_value, usertypes.Unset): return py_value elif not py_value: return None # Check for duplicate values if len(set(py_value)) != len(py_value): raise configexc.ValidationError( py_value, "String contains duplicate values!") return py_value class List(BaseType): """A list of values. When setting from a string, pass a json-like list, e.g. `["one", "two"]`. """ _show_valtype = True def __init__( self, valtype: BaseType, *, length: int = None, none_ok: bool = False, completions: _Completions = None, ) -> None: super().__init__(none_ok=none_ok, completions=completions) self.valtype = valtype self.length = length def get_name(self) -> str: name = super().get_name() if self._show_valtype: name += " of " + self.valtype.get_name() return name def get_valid_values(self) -> Optional[ValidValues]: return self.valtype.get_valid_values() def from_str(self, value: str) -> Optional[ListType]: self._basic_str_validation(value) if not value: return None try: yaml_val = utils.yaml_load(value) except yaml.YAMLError as e: raise configexc.ValidationError(value, str(e)) # For the values, we actually want to call to_py, as we did parse them # from YAML, so they are numbers/booleans/... already. self.to_py(yaml_val) return yaml_val def from_obj(self, value: Optional[ListType]) -> ListType: if value is None: return [] return [self.valtype.from_obj(v) for v in value] def to_py( self, value: Union[ListType, usertypes.Unset] ) -> Union[ListType, usertypes.Unset]: self._basic_py_validation(value, list) if isinstance(value, usertypes.Unset): return value elif not value: return [] for val in value: self._validate_surrogate_escapes(value, val) if self.length is not None and len(value) != self.length: raise configexc.ValidationError(value, "Exactly {} values need to " "be set!".format(self.length)) return [self.valtype.to_py(v) for v in value] def to_str(self, value: ListType) -> str: if not value: # An empty list is treated just like None -> empty string return '' return json.dumps(value) def to_doc(self, value: ListType, indent: int = 0) -> str: if not value: return 'empty' # Might work, but untested assert not isinstance(self.valtype, (Dict, List)), self.valtype lines = ['\n'] prefix = '-' if not indent else '*' * indent for elem in value: lines.append('{} {}'.format( prefix, self.valtype.to_doc(elem, indent=indent+1))) return '\n'.join(lines) def __repr__(self) -> str: return utils.get_repr(self, none_ok=self.none_ok, valtype=self.valtype, length=self.length) class ListOrValue(BaseType): """A list of values, or a single value. // Internally, the value is stored as either a value (of valtype), or a list. to_py() then ensures that it's always a list. """ _show_valtype = True def __init__( self, valtype: BaseType, *, none_ok: bool = False, completions: _Completions = None, **kwargs: Any, ) -> None: super().__init__(none_ok=none_ok, completions=completions) assert not isinstance(valtype, (List, ListOrValue)), valtype self.listtype = List(valtype=valtype, none_ok=none_ok, **kwargs) self.valtype = valtype def _val_and_type(self, value: Any) -> Tuple[Any, BaseType]: """Get the value and type to use for to_str/to_doc/from_str.""" if isinstance(value, list): if len(value) == 1: return value[0], self.valtype else: return value, self.listtype else: return value, self.valtype def get_name(self) -> str: return self.listtype.get_name() + ', or ' + self.valtype.get_name() def get_valid_values(self) -> Optional[ValidValues]: return self.valtype.get_valid_values() def from_str(self, value: str) -> Any: try: return self.listtype.from_str(value) except configexc.ValidationError: return self.valtype.from_str(value) def from_obj(self, value: Any) -> Any: if value is None: return [] return value def to_py(self, value: Any) -> Any: if isinstance(value, usertypes.Unset): return value try: return [self.valtype.to_py(value)] except configexc.ValidationError: return self.listtype.to_py(value) def to_str(self, value: Any) -> str: if value is None: return '' val, typ = self._val_and_type(value) return typ.to_str(val) def to_doc(self, value: Any, indent: int = 0) -> str: if value is None: return 'empty' val, typ = self._val_and_type(value) return typ.to_doc(val) def __repr__(self) -> str: return utils.get_repr(self, none_ok=self.none_ok, valtype=self.valtype) class FlagList(List): """A list of flags. Lists with duplicate flags are invalid. Each item is checked against the valid values of the setting. """ combinable_values: Optional[Sequence] = None _show_valtype = False def __init__( self, *, none_ok: bool = False, completions: _Completions = None, valid_values: ValidValues = None, length: int = None, ) -> None: super().__init__( valtype=String(), none_ok=none_ok, length=length, completions=completions, ) self.valtype.valid_values = valid_values def _check_duplicates(self, values: ListType) -> None: if len(set(values)) != len(values): raise configexc.ValidationError( values, "List contains duplicate values!") def to_py( self, value: Union[usertypes.Unset, ListType], ) -> Union[usertypes.Unset, ListType]: vals = super().to_py(value) if not isinstance(vals, usertypes.Unset): self._check_duplicates(vals) return vals def complete(self) -> _Completions: if self._completions is not None: return self._completions valid_values = self.valtype.valid_values if valid_values is None: return None out = [] # Single value completions for value in valid_values: desc = valid_values.descriptions.get(value, "") out.append((json.dumps([value]), desc)) combinables = self.combinable_values if combinables is None: combinables = list(valid_values) # Generate combinations of each possible value combination for size in range(2, len(combinables) + 1): for combination in itertools.combinations(combinables, size): out.append((json.dumps(combination), '')) return out def __repr__(self) -> str: return utils.get_repr(self, none_ok=self.none_ok, valid_values=self.valid_values, length=self.length) class Bool(BaseType): """A boolean setting, either `true` or `false`. When setting from a string, `1`, `yes`, `on` and `true` count as true, while `0`, `no`, `off` and `false` count as false (case-insensitive). """ def __init__( self, *, none_ok: bool = False, completions: _Completions = None ) -> None: super().__init__(none_ok=none_ok, completions=completions) self.valid_values = ValidValues('true', 'false', generate_docs=False) def to_py(self, value: Union[bool, str, None]) -> Optional[bool]: self._basic_py_validation(value, bool) assert not isinstance(value, str) return value def from_str(self, value: str) -> Optional[bool]: self._basic_str_validation(value) if not value: return None try: return BOOLEAN_STATES[value.lower()] except KeyError: raise configexc.ValidationError(value, "must be a boolean!") def to_str(self, value: Optional[bool]) -> str: mapping = { None: '', True: 'true', False: 'false', } return mapping[value] class BoolAsk(Bool): """Like `Bool`, but `ask` is allowed as additional value.""" def __init__( self, *, none_ok: bool = False, completions: _Completions = None, ) -> None: super().__init__(none_ok=none_ok, completions=completions) self.valid_values = ValidValues('true', 'false', 'ask') def to_py(self, # type: ignore[override] value: Union[bool, str]) -> Union[bool, str, None]: # basic validation unneeded if it's == 'ask' and done by Bool if we # call super().to_py if isinstance(value, str) and value.lower() == 'ask': return 'ask' return super().to_py(value) def from_str(self, # type: ignore[override] value: str) -> Union[bool, str, None]: # basic validation unneeded if it's == 'ask' and done by Bool if we # call super().from_str if value.lower() == 'ask': return 'ask' return super().from_str(value) def to_str(self, value: Union[bool, str, None]) -> str: mapping = { None: '', True: 'true', False: 'false', 'ask': 'ask', } return mapping[value] class _Numeric(BaseType): # pylint: disable=abstract-method """Base class for Float/Int. Attributes: minval: Minimum value (inclusive). maxval: Maximum value (inclusive). """ def __init__( self, *, minval: int = None, maxval: int = None, zero_ok: bool = True, none_ok: bool = False, completions: _Completions = None, ) -> None: super().__init__(none_ok=none_ok, completions=completions) self.minval = self._parse_bound(minval) self.maxval = self._parse_bound(maxval) self.zero_ok = zero_ok if self.maxval is not None and self.minval is not None: if self.maxval < self.minval: raise ValueError("minval ({}) needs to be <= maxval ({})!" .format(self.minval, self.maxval)) def _parse_bound( self, bound: Union[None, str, int, float] ) -> Union[None, int, float]: """Get a numeric bound from a string like 'maxint'.""" if bound == 'maxint': return qtutils.MAXVALS['int'] elif bound == 'maxint64': return qtutils.MAXVALS['int64'] else: if bound is not None: assert isinstance(bound, (int, float)), bound return bound def _validate_bounds(self, value: Union[int, float, _UnsetNone], suffix: str = '') -> None: """Validate self.minval and self.maxval.""" if value is None: return elif isinstance(value, usertypes.Unset): return elif self.minval is not None and value < self.minval: raise configexc.ValidationError( value, "must be {}{} or bigger!".format(self.minval, suffix)) elif self.maxval is not None and value > self.maxval: raise configexc.ValidationError( value, "must be {}{} or smaller!".format(self.maxval, suffix)) elif not self.zero_ok and value == 0: raise configexc.ValidationError(value, "must not be 0!") def to_str(self, value: Union[None, int, float]) -> str: if value is None: return '' return str(value) def __repr__(self) -> str: return utils.get_repr(self, none_ok=self.none_ok, minval=self.minval, maxval=self.maxval) class Int(_Numeric): """Base class for an integer setting.""" def from_str(self, value: str) -> Optional[int]: self._basic_str_validation(value) if not value: return None try: intval = int(value) except ValueError: raise configexc.ValidationError(value, "must be an integer!") self.to_py(intval) return intval def to_py(self, value: Union[int, _UnsetNone]) -> Union[int, _UnsetNone]: self._basic_py_validation(value, int) self._validate_bounds(value) return value class Float(_Numeric): """Base class for a float setting.""" def from_str(self, value: str) -> Optional[float]: self._basic_str_validation(value) if not value: return None try: floatval = float(value) except ValueError: raise configexc.ValidationError(value, "must be a float!") self.to_py(floatval) return floatval def to_py( self, value: Union[int, float, _UnsetNone], ) -> Union[int, float, _UnsetNone]: self._basic_py_validation(value, (int, float)) self._validate_bounds(value) return value class Perc(_Numeric): """A percentage.""" def to_py( self, value: Union[float, int, str, _UnsetNone] ) -> Union[float, int, _UnsetNone]: self._basic_py_validation(value, (float, int, str)) if isinstance(value, usertypes.Unset): return value elif not value: return None if isinstance(value, str): value = value.rstrip('%') try: value = float(value) except ValueError: raise configexc.ValidationError( value, "must be a valid number!") self._validate_bounds(value, suffix='%') return value def to_str(self, value: Union[None, float, int, str]) -> str: if value is None: return '' elif isinstance(value, str): return value else: return '{}%'.format(value) class PercOrInt(_Numeric): """Percentage or integer. Attributes: minperc: Minimum value for percentage (inclusive). maxperc: Maximum value for percentage (inclusive). minint: Minimum value for integer (inclusive). maxint: Maximum value for integer (inclusive). """ def __init__( self, *, minperc: int = None, maxperc: int = None, minint: int = None, maxint: int = None, none_ok: bool = False, completions: _Completions = None, ) -> None: super().__init__( minval=minint, maxval=maxint, none_ok=none_ok, completions=completions, ) self.minperc = self._parse_bound(minperc) self.maxperc = self._parse_bound(maxperc) if (self.maxperc is not None and self.minperc is not None and self.maxperc < self.minperc): raise ValueError("minperc ({}) needs to be <= maxperc " "({})!".format(self.minperc, self.maxperc)) def from_str(self, value: str) -> Union[None, str, int]: self._basic_str_validation(value) if not value: return None if value.endswith('%'): self.to_py(value) return value try: intval = int(value) except ValueError: raise configexc.ValidationError(value, "must be integer or percentage!") self.to_py(intval) return intval def to_py(self, value: Union[None, str, int]) -> Union[None, str, int]: """Expect a value like '42%' as string, or 23 as int.""" self._basic_py_validation(value, (int, str)) if value is None: return None if isinstance(value, str): if not value.endswith('%'): raise configexc.ValidationError( value, "needs to end with % or be an integer") try: intval = int(value[:-1]) except ValueError: raise configexc.ValidationError(value, "invalid percentage!") if self.minperc is not None and intval < self.minperc: raise configexc.ValidationError(value, "must be {}% or " "more!".format(self.minperc)) if self.maxperc is not None and intval > self.maxperc: raise configexc.ValidationError(value, "must be {}% or " "less!".format(self.maxperc)) # Note we don't actually return the integer here, as we need to # know whether it was a percentage. else: self._validate_bounds(value) return value def __repr__(self) -> str: return utils.get_repr(self, none_ok=self.none_ok, minint=self.minval, maxint=self.maxval, minperc=self.minperc, maxperc=self.maxperc) class Command(BaseType): """A qutebrowser command with arguments. // Since validation is quite tricky here, we don't do so, and instead let invalid commands (in bindings/aliases) fail when used. """ def complete(self) -> _Completions: if self._completions is not None: return self._completions out = [] for cmdname, obj in objects.commands.items(): out.append((cmdname, obj.desc)) return out def to_py(self, value: str) -> str: self._basic_py_validation(value, str) return value class ColorSystem(MappingType): """The color system to use for color interpolation.""" MAPPING = { 'rgb': (QColor.Rgb, "Interpolate in the RGB color system."), 'hsv': (QColor.Hsv, "Interpolate in the HSV color system."), 'hsl': (QColor.Hsl, "Interpolate in the HSL color system."), 'none': (None, "Don't show a gradient."), } class IgnoreCase(MappingType): """Whether to search case insensitively.""" MAPPING = { 'always': (usertypes.IgnoreCase.always, "Search case-insensitively."), 'never': (usertypes.IgnoreCase.never, "Search case-sensitively."), 'smart': ( usertypes.IgnoreCase.smart, "Search case-sensitively if there are capital characters." ), } class QtColor(BaseType): """A color value. A value can be in one of the following formats: * `#RGB`/`#RRGGBB`/`#AARRGGBB`/`#RRRGGGBBB`/`#RRRRGGGGBBBB` * An SVG color name as specified in https://www.w3.org/TR/SVG/types.html#ColorKeywords[the W3C specification]. * transparent (no color) * `rgb(r, g, b)` / `rgba(r, g, b, a)` (values 0-255 or percentages) * `hsv(h, s, v)` / `hsva(h, s, v, a)` (values 0-255, hue 0-359) """ def _parse_value(self, kind: str, val: str) -> int: try: return int(val) except ValueError: pass mult = 359.0 if kind == 'h' else 255.0 if val.endswith('%'): val = val[:-1] mult = mult / 100 try: return int(float(val) * mult) except ValueError: raise configexc.ValidationError(val, "must be a valid color value") def to_py(self, value: _StrUnset) -> Union[_UnsetNone, QColor]: self._basic_py_validation(value, str) if isinstance(value, usertypes.Unset): return value elif not value: return None if '(' in value and value.endswith(')'): openparen = value.index('(') kind = value[:openparen] vals = value[openparen+1:-1].split(',') converters: DictType[str, Callable[..., QColor]] = { 'rgba': QColor.fromRgb, 'rgb': QColor.fromRgb, 'hsva': QColor.fromHsv, 'hsv': QColor.fromHsv, } conv = converters.get(kind) if not conv: raise configexc.ValidationError( value, '{} not in {}'.format(kind, sorted(converters))) if len(kind) != len(vals): raise configexc.ValidationError( value, 'expected {} values for {}'.format(len(kind), kind)) int_vals = [self._parse_value(kind, val) for kind, val in zip(kind, vals)] return conv(*int_vals) color = QColor(value) if color.isValid(): return color else: raise configexc.ValidationError(value, "must be a valid color") class QssColor(BaseType): """A color value supporting gradients. A value can be in one of the following formats: * `#RGB`/`#RRGGBB`/`#AARRGGBB`/`#RRRGGGBBB`/`#RRRRGGGGBBBB` * An SVG color name as specified in https://www.w3.org/TR/SVG/types.html#ColorKeywords[the W3C specification]. * transparent (no color) * `rgb(r, g, b)` / `rgba(r, g, b, a)` (values 0-255 or percentages) * `hsv(h, s, v)` / `hsva(h, s, v, a)` (values 0-255, hue 0-359) * A gradient as explained in https://doc.qt.io/qt-5/stylesheet-reference.html#list-of-property-types[the Qt documentation] under ``Gradient'' """ def to_py(self, value: _StrUnset) -> _StrUnsetNone: self._basic_py_validation(value, str) if isinstance(value, usertypes.Unset): return value elif not value: return None functions = ['rgb', 'rgba', 'hsv', 'hsva', 'qlineargradient', 'qradialgradient', 'qconicalgradient'] if (any(value.startswith(func + '(') for func in functions) and value.endswith(')')): # QColor doesn't handle these return value if not QColor.isValidColor(value): raise configexc.ValidationError(value, "must be a valid color") return value class FontBase(BaseType): """Base class for Font/FontFamily.""" # Gets set when the config is initialized. default_family: Optional[str] = None default_size: Optional[str] = None font_regex = re.compile(r""" ( ( # style (?P