diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 0ce5aadd1..5efa6f2af 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -29,6 +29,7 @@ import warnings import datetime import functools import operator +import json import yaml from PyQt5.QtCore import QUrl, Qt @@ -199,19 +200,15 @@ class BaseType: """ raise NotImplementedError - # def to_str(self, value): - # """Get a string from the setting value. + def to_str(self, value): + """Get a string from the setting value. - # The resulting string should be parseable again by from_str. - # """ - # raise NotImplementedError - - # def to_py(self, value): - # """Get a Python/YAML value from the setting value. - - # The resulting value should be parseable again by from_py. - # """ - # raise NotImplementedError + The resulting string should be parseable again by from_str. + """ + if value is None: + return '' + assert isinstance(value, str) + return value def complete(self): """Return a list of possible values for completion. @@ -258,11 +255,6 @@ class MappingType(BaseType): self._validate_valid_values(value.lower()) return self.MAPPING[value.lower()] - # def to_str(self, value): - # reverse_mapping = {v: k for k, v in self.MAPPING.items()} - # assert len(self.MAPPING) == len(reverse_mapping) - # return reverse_mapping[value] - class String(BaseType): @@ -401,6 +393,11 @@ class List(BaseType): "be set!".format(self.length)) return [self.valtype.from_py(v) for v in value] + def to_str(self, value): + if value is None: + return '' + return json.dumps(value) + class FlagList(List): @@ -472,6 +469,14 @@ class Bool(BaseType): except KeyError: raise configexc.ValidationError(value, "must be a boolean!") + def to_str(self, value): + mapping = { + None: '', + True: 'true', + False: 'false', + } + return mapping[value] + class BoolAsk(Bool): @@ -495,6 +500,15 @@ class BoolAsk(Bool): return 'ask' return super().from_str(value) + def to_str(self, value): + mapping = { + None: '', + True: 'true', + False: 'false', + 'ask': 'ask', + } + return mapping[value] + class _Numeric(BaseType): # pylint: disable=abstract-method @@ -536,6 +550,11 @@ class _Numeric(BaseType): # pylint: disable=abstract-method raise configexc.ValidationError( value, "must be {}{} or smaller!".format(self.maxval, suffix)) + def to_str(self, value): + if value is None: + return '' + return str(value) + class Int(_Numeric): @@ -597,6 +616,11 @@ class Perc(_Numeric): self._validate_bounds(floatval, suffix='%') return floatval + def to_str(self, value): + if value is None: + return '' + return value + class PercOrInt(_Numeric): @@ -637,8 +661,8 @@ class PercOrInt(_Numeric): def from_py(self, value): """Expect a value like '42%' as string, or 23 as int.""" self._basic_py_validation(value, (int, str)) - if not value: - return + if value is None: + return None if isinstance(value, str): if not value.endswith('%'): @@ -913,6 +937,14 @@ class Regex(BaseType): # FIXME:conf is it okay if we ignore flags here? return value + def to_str(self, value): + if value is None: + return '' + elif isinstance(value, self._regex_type): + return value.pattern + else: + return value + class Dict(BaseType): @@ -954,6 +986,11 @@ class Dict(BaseType): return {self.keytype.from_py(key): self.valtype.from_py(val) for key, val in value.items()} + def to_str(self, value): + if value is None: + return '' + return json.dumps(value) + class File(BaseType): diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index bc1af3b9c..f74f6c34a 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -22,6 +22,7 @@ import re import os import sys import json +import math import collections import itertools import warnings @@ -213,10 +214,31 @@ class TestAll: @hypothesis.given(strategies.text()) @hypothesis.example('\x00') def test_from_str_hypothesis(self, klass, s): + typ = klass() try: - klass().from_str(s) + val = typ.from_str(s) except configexc.ValidationError: - pass + return + + # For some types, we don't actually get the internal (YAML-like) value + # back from from_str(), so we can't convert it back. + if klass in [configtypes.FuzzyUrl, configtypes.QtFont, + configtypes.ShellCommand, configtypes.Url]: + return + + converted = typ.to_str(val) + # For those we only check that to_str doesn't crash, but we can't be + # sure we get the 100% same value back. + if klass in [ + configtypes.Bool, # on -> true + configtypes.BoolAsk, # ditto + configtypes.Float, # 1.0 -> 1 + configtypes.Int, # 00 -> 0 + configtypes.PercOrInt, # ditto + ]: + return + + assert converted == s def test_none_ok_true(self, klass): """Test None and empty string values with none_ok=True.""" @@ -235,6 +257,9 @@ class TestAll: with pytest.raises(configexc.ValidationError): meth(value) + def test_to_str_none(self, klass): + assert klass().to_str(None) == '' + def test_invalid_python_type(self, klass): """Make sure every type fails when passing an invalid Python type.""" with pytest.raises(configexc.ValidationError): @@ -349,6 +374,9 @@ class TestMappingType: with pytest.raises(configexc.ValidationError): klass().from_py(val) + def test_to_str(self, klass): + assert klass().to_str('one') == 'one' + @pytest.mark.parametrize('typ', [configtypes.ColorSystem(), configtypes.Position(), configtypes.SelectOnRemove()]) @@ -523,20 +551,29 @@ class TestList: expected = configtypes.ValidValues('foo', 'bar', 'baz') assert klass().get_valid_values() == expected + def test_to_str(self, klass): + assert klass().to_str(["a", True] == '["a", true]') + @hypothesis.given(val=strategies.lists(strategies.just('foo'))) def test_hypothesis(self, klass, val): + typ = klass(none_ok_outer=True) try: - klass().from_py(val) + converted = typ.from_py(val) except configexc.ValidationError: pass + else: + assert typ.from_str(typ.to_str(converted)) == converted @hypothesis.given(val=strategies.lists(strategies.just('foo'))) def test_hypothesis_text(self, klass, val): + typ = klass() text = json.dumps(val) try: - klass().from_str(text) + converted = typ.from_str(text) except configexc.ValidationError: pass + else: + assert typ.to_str(converted) == text class TestFlagList: @@ -622,6 +659,13 @@ class TestBool: with pytest.raises(configexc.ValidationError): klass().from_py(42) + @pytest.mark.parametrize('val, expected', [ + (True, 'true'), + (False, 'false'), + ]) + def test_to_str(self, klass, val, expected): + assert klass().to_str(val) == expected + class TestBoolAsk: @@ -654,6 +698,14 @@ class TestBoolAsk: with pytest.raises(configexc.ValidationError): klass().from_py(42) + @pytest.mark.parametrize('val, expected', [ + (True, 'true'), + (False, 'false'), + ('ask', 'ask'), + ]) + def test_to_str(self, klass, val, expected): + assert klass().to_str(val) == expected + class TestNumeric: @@ -744,12 +796,16 @@ class TestInt: @hypothesis.given(val=strategies.integers()) def test_hypothesis(self, klass, val): - klass().from_py(val) + typ = klass() + converted = typ.from_py(val) + assert typ.from_str(typ.to_str(converted)) == converted @hypothesis.given(val=strategies.integers()) def test_hypothesis_text(self, klass, val): text = json.dumps(val) - klass().from_str(text) + typ = klass() + converted = typ.from_str(text) + assert typ.to_str(converted) == text class TestFloat: @@ -794,13 +850,21 @@ class TestFloat: @hypothesis.given(val=strategies.one_of(strategies.floats(), strategies.integers())) def test_hypothesis(self, klass, val): - klass().from_py(val) + typ = klass() + converted = typ.from_py(val) + converted_2 = typ.from_str(typ.to_str(converted)) + if math.isnan(converted): + assert math.isnan(converted_2) + else: + assert converted == converted_2 @hypothesis.given(val=strategies.one_of(strategies.floats(), strategies.integers())) def test_hypothesis_text(self, klass, val): text = json.dumps(val) klass().from_str(text) + # Can't check for string equality with to_str here, as we can get 1.0 + # for 1. class TestPerc: @@ -847,6 +911,9 @@ class TestPerc: with pytest.raises(configexc.ValidationError): klass(**kwargs).from_py(val) + def test_to_str(self, klass): + assert klass().to_str('42%') == '42%' + class TestPercOrInt: @@ -909,13 +976,30 @@ class TestPercOrInt: with pytest.raises(configexc.ValidationError): klass().from_py(val) - @hypothesis.given(val=strategies.one_of(strategies.integers(), - strategies.text())) + @hypothesis.given(val=strategies.one_of( + strategies.integers(), + strategies.integers().map(lambda n: str(n) + '%'), + strategies.text())) def test_hypothesis(self, klass, val): + typ = klass(none_ok=True) try: - klass().from_py(val) + converted = typ.from_py(val) except configexc.ValidationError: pass + else: + assert typ.from_str(typ.to_str(converted)) == converted + + @hypothesis.given(text=strategies.one_of( + strategies.integers().map(str), + strategies.integers().map(lambda n: str(n) + '%'))) + def test_hypothesis_text(self, klass, text): + typ = klass() + try: + converted = typ.from_str(text) + except configexc.ValidationError: + pass + else: + assert typ.to_str(converted) == text class TestCommand: @@ -1248,6 +1332,10 @@ class TestRegex: typ = klass(flags=flags) assert typ.flags == expected + @pytest.mark.parametrize('value', [r'foobar', re.compile(r'foobar')]) + def test_to_str(self, klass, value): + assert klass().to_str(value) == 'foobar' + class TestDict: @@ -1304,8 +1392,10 @@ class TestDict: strategies.booleans())) def test_hypothesis(self, klass, val): d = klass(keytype=configtypes.Bool(), - valtype=configtypes.Bool()) - d.from_py(val) + valtype=configtypes.Bool(), + none_ok=True) + converted = d.from_py(val) + assert d.from_str(d.to_str(converted)) == converted @hypothesis.given(val=strategies.dictionaries(strategies.booleans(), strategies.booleans())) @@ -1313,9 +1403,11 @@ class TestDict: text = json.dumps(val) d = klass(keytype=configtypes.Bool(), valtype=configtypes.Bool()) try: - d.from_str(text) + converted = d.from_str(text) except configexc.ValidationError: pass + else: + assert d.to_str(converted) == text def unrequired_class(**kwargs):