diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index b40d03237..616750c78 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -1944,7 +1944,7 @@ Default: +pass:[ask]+ === content.user_stylesheets A list of user stylesheet filenames to use. -Type: <> +Type: <> Default: empty @@ -3056,11 +3056,9 @@ Default: === url.start_pages The page(s) to open at the start. -Type: <> +Type: <> -Default: - -- +pass:[https://start.duckduckgo.com]+ +Default: +pass:[https://start.duckduckgo.com]+ [[url.yank_ignored_parameters]] === url.yank_ignored_parameters @@ -3199,6 +3197,7 @@ Lists with duplicate flags are invalid. Each item is checked against the valid v |List|A list of values. When setting from a string, pass a json-like list, e.g. `["one", "two"]`. +|ListOrValue|A list of values, or a single value. |NewTabPosition|How new tabs are positioned. |Padding|Setting for paddings around elements. |Perc|A percentage. diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 739086628..d58168ab3 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -93,7 +93,7 @@ def _parse_yaml_type(name, node): if typ is configtypes.Dict: kwargs['keytype'] = _parse_yaml_type(name, kwargs['keytype']) kwargs['valtype'] = _parse_yaml_type(name, kwargs['valtype']) - elif typ is configtypes.List: + elif typ is configtypes.List or typ is configtypes.ListOrValue: kwargs['valtype'] = _parse_yaml_type(name, kwargs['valtype']) except KeyError as e: _raise_invalid_node(name, str(e), node) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index a4467ac08..2055e518d 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -545,7 +545,7 @@ content.ssl_strict: content.user_stylesheets: type: - name: List + name: ListOrValue valtype: File none_ok: True default: null @@ -1240,9 +1240,9 @@ url.searchengines: url.start_pages: type: - name: List + name: ListOrValue valtype: FuzzyUrl - default: ["https://start.duckduckgo.com"] + default: "https://start.duckduckgo.com" desc: The page(s) to open at the start. url.yank_ignored_parameters: diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 32b1fc872..499b0c994 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -476,6 +476,67 @@ class List(BaseType): return '\n'.join(lines) +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, none_ok=False, *args, **kwargs): + super().__init__(none_ok) + assert not isinstance(valtype, (List, ListOrValue)), valtype + self.listtype = List(valtype, none_ok=none_ok, *args, **kwargs) + self.valtype = valtype + + def get_name(self): + return self.listtype.get_name() + ' or ' + self.valtype.get_name() + + def get_valid_values(self): + return self.valtype.get_valid_values() + + def from_str(self, value): + try: + return self.listtype.from_str(value) + except configexc.ValidationError: + return self.valtype.from_str(value) + + def to_py(self, value): + try: + return [self.valtype.to_py(value)] + except configexc.ValidationError: + return self.listtype.to_py(value) + + def to_str(self, value): + if value is None: + return '' + + if isinstance(value, list): + if len(value) == 1: + return self.valtype.to_str(value[0]) + else: + return self.listtype.to_str(value) + else: + return self.valtype.to_str(value) + + def to_doc(self, value): + if value is None: + return 'empty' + + if isinstance(value, list): + if len(value) == 1: + return self.valtype.to_doc(value[0]) + else: + return self.listtype.to_doc(value) + else: + return self.valtype.to_doc(value) + + class FlagList(List): """A list of flags. diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 55a321307..5030f2c51 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -193,7 +193,8 @@ class TestAll: if member in [configtypes.BaseType, configtypes.MappingType, configtypes._Numeric]: pass - elif member is configtypes.List: + elif (member is configtypes.List or + member is configtypes.ListOrValue): yield functools.partial(member, valtype=configtypes.Int()) yield functools.partial(member, valtype=configtypes.Url()) elif member is configtypes.Dict: @@ -240,6 +241,9 @@ class TestAll: configtypes.PercOrInt, # ditto ]: return + if (isinstance(typ, configtypes.ListOrValue) and + isinstance(typ.valtype, configtypes.Int)): + return assert converted == s @@ -250,7 +254,7 @@ class TestAll: to_py_expected = configtypes.PaddingValues(None, None, None, None) elif isinstance(typ, configtypes.Dict): to_py_expected = {} - elif isinstance(typ, configtypes.List): + elif isinstance(typ, (configtypes.List, configtypes.ListOrValue)): to_py_expected = [] else: to_py_expected = None @@ -670,6 +674,99 @@ class TestFlagList: assert klass().complete() is None +class TestListOrValue: + + @pytest.fixture + def klass(self): + return configtypes.ListOrValue + + @pytest.fixture + def strtype(self): + return configtypes.String() + + @pytest.mark.parametrize('val, expected', [ + ('["foo"]', ['foo']), + ('["foo", "bar"]', ['foo', 'bar']), + ('foo', 'foo'), + ]) + def test_from_str(self, klass, strtype, val, expected): + assert klass(strtype).from_str(val) == expected + + def test_from_str_invalid(self, klass): + valtype = configtypes.String(minlen=10) + with pytest.raises(configexc.ValidationError): + klass(valtype).from_str('123') + + @pytest.mark.parametrize('val, expected', [ + (['foo'], ['foo']), + ('foo', ['foo']), + ]) + def test_to_py_valid(self, klass, strtype, val, expected): + assert klass(strtype).to_py(val) == expected + + @pytest.mark.parametrize('val', [[42], ['\U00010000']]) + def test_to_py_invalid(self, klass, strtype, val): + with pytest.raises(configexc.ValidationError): + klass(strtype).to_py(val) + + @pytest.mark.parametrize('val', [None, ['foo', 'bar'], 'abcd']) + def test_to_py_length(self, strtype, klass, val): + klass(strtype, none_ok=True, length=2).to_py(val) + + @pytest.mark.parametrize('val', [['a'], ['a', 'b'], ['a', 'b', 'c', 'd']]) + def test_wrong_length(self, strtype, klass, val): + with pytest.raises(configexc.ValidationError, + match='Exactly 3 values need to be set!'): + klass(strtype, length=3).to_py(val) + + def test_get_name(self, strtype, klass): + assert klass(strtype).get_name() == 'List of String or String' + + def test_get_valid_values(self, klass): + valid_values = configtypes.ValidValues('foo', 'bar', 'baz') + valtype = configtypes.String(valid_values=valid_values) + assert klass(valtype).get_valid_values() == valid_values + + def test_to_str(self, strtype, klass): + assert klass(strtype).to_str(["a", True]) == '["a", true]' + + @hypothesis.given(val=strategies.lists(strategies.just('foo'))) + def test_hypothesis(self, strtype, klass, val): + typ = klass(strtype, none_ok=True) + try: + converted = typ.to_py(val) + except configexc.ValidationError: + pass + else: + expected = converted if converted else [] + assert typ.to_py(typ.from_str(typ.to_str(converted))) == expected + + @hypothesis.given(val=strategies.lists(strategies.just('foo'))) + def test_hypothesis_text(self, strtype, klass, val): + typ = klass(strtype) + text = json.dumps(val) + try: + typ.to_str(typ.from_str(text)) + except configexc.ValidationError: + pass + + @pytest.mark.parametrize('val, expected', [ + # simple list + (['foo', 'bar'], '\n\n- +pass:[foo]+\n- +pass:[bar]+'), + # only one value + (['foo'], '+pass:[foo]+'), + # value without list + ('foo', '+pass:[foo]+'), + # empty + ([], 'empty'), + (None, 'empty'), + ]) + def test_to_doc(self, klass, strtype, val, expected): + doc = klass(strtype).to_doc(val) + print(doc) + assert doc == expected + + class TestBool: TESTS = {