This commit is contained in:
Robert Craigie 2026-01-07 16:37:01 -08:00 committed by GitHub
commit 5ccf31fe3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2819 additions and 15 deletions

View File

@ -20,7 +20,7 @@ from qutebrowser.config import config
#: This also supports setting configuration values::
#:
#: config.val.content.javascript.enabled = False
val = cast('config.ConfigContainer', None)
val = cast('config.ConfigContainerInternal', None)
def get(name: str, url: QUrl = None) -> Any:

View File

@ -0,0 +1,8 @@
from typing import cast
from qutebrowser.config.configcontainer_types import ConfigContainer # pylint: disable=unused-import
from qutebrowser.config import configfiles # pylint: disable=unused-import
c = cast('ConfigContainer', None)
config = cast('configfiles.ConfigAPI', None)

View File

@ -18,12 +18,16 @@ from qutebrowser.utils import utils, log, urlmatch
from qutebrowser.misc import objects
from qutebrowser.keyinput import keyutils
# alias to the generated type for back-compat
from qutebrowser.config.configcontainer_types import ConfigContainer # pylint: disable=unused-import
if TYPE_CHECKING:
from qutebrowser.config import configcache, configfiles
from qutebrowser.misc import savemanager
# An easy way to access the config from other code via config.val.foo
val = cast('ConfigContainer', None)
val = cast('ConfigContainerInternal', None)
instance = cast('Config', None)
key_instance = cast('KeyConfig', None)
cache = cast('configcache.ConfigCache', None)
@ -569,7 +573,7 @@ class Config(QObject):
return '\n'.join(lines)
class ConfigContainer:
class ConfigContainerInternal:
"""An object implementing config access via __getattr__.
@ -607,9 +611,9 @@ class ConfigContainer:
text = f"While {action}"
self._configapi.errors.append(configexc.ConfigErrorDesc(text, e))
def _with_prefix(self, prefix: str) -> 'ConfigContainer':
def _with_prefix(self, prefix: str) -> 'ConfigContainerInternal':
"""Get a new ConfigContainer for the given prefix."""
return ConfigContainer(
return ConfigContainerInternal(
config=self._config,
configapi=self._configapi,
pattern=self._pattern,

File diff suppressed because it is too large Load Diff

View File

@ -21,12 +21,14 @@ import yaml
from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QObject, QSettings, qVersion
import qutebrowser
from qutebrowser.api import configpy
from qutebrowser.config import (configexc, config, configdata, configutils,
configtypes)
from qutebrowser.keyinput import keyutils
from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch, version
if TYPE_CHECKING:
from qutebrowser.config.configcontainer_types import ConfigContainer
from qutebrowser.misc import savemanager
@ -787,12 +789,12 @@ class ConfigAPI:
self.errors += e.errors
@contextlib.contextmanager
def pattern(self, pattern: str) -> Iterator[config.ConfigContainer]:
def pattern(self, pattern: str) -> Iterator[config.ConfigContainerInternal]:
"""Get a ConfigContainer for the given pattern."""
# We need to propagate the exception so we don't need to return
# something.
urlpattern = urlmatch.UrlPattern(pattern)
container = config.ConfigContainer(config=self._config, configapi=self,
container = config.ConfigContainerInternal(config=self._config, configapi=self,
pattern=urlpattern)
yield container
@ -860,6 +862,8 @@ class ConfigPyWriter:
yield self._line("# qute://help/configuring.html")
yield self._line("# qute://help/settings.html")
yield ''
yield 'from qutebrowser.api.configpy import c, config'
yield ''
if self._commented:
# When generated from an autoconfig.yml with commented=False,
# we don't want to load that autoconfig.yml anymore.
@ -950,9 +954,14 @@ def read_config_py(
config.key_instance,
warn_autoconfig=warn_autoconfig,
)
container = config.ConfigContainer(config.instance, configapi=api)
container = config.ConfigContainerInternal(config.instance, configapi=api)
basename = os.path.basename(filename)
# used for `from qutebrowser.api.configpy import c, config`
configpy.config = api
configpy.c = cast('ConfigContainer', container)
module = types.ModuleType('config')
module.config = api # type: ignore[attr-defined]
module.c = container # type: ignore[attr-defined]

View File

@ -30,8 +30,8 @@ def early_init(args: argparse.Namespace) -> None:
yaml_config = configfiles.YamlConfig()
config.instance = config.Config(yaml_config=yaml_config)
config.val = config.ConfigContainer(config.instance)
configapi.val = config.ConfigContainer(config.instance)
config.val = config.ConfigContainerInternal(config.instance)
configapi.val = config.ConfigContainerInternal(config.instance)
config.key_instance = config.KeyConfig(config.instance)
config.cache = configcache.ConfigCache()
yaml_config.setParent(config.instance)

View File

@ -36,6 +36,7 @@ import functools
import operator
import json
import dataclasses
from abc import abstractmethod
from typing import Any, Optional, Union
from re import Pattern
from collections.abc import Iterable, Iterator, Sequence, Callable
@ -131,6 +132,13 @@ class ValidValues:
if desc is not None:
self.descriptions[val] = desc
def py_type(self) -> str:
"""Generate a `Literal` Python type annotation for the valid values."""
typ = f'Literal[{", ".join(repr(k) for k in self.values)}]'
if self.others_permitted:
return f'Union[{typ}, str]'
return typ
def __contains__(self, val: str) -> bool:
return val in self.values
@ -305,6 +313,21 @@ class BaseType:
"""
raise NotImplementedError
def py_type(self) -> str:
"""Generate a Python type annotation for this type."""
typ = self.valid_values.py_type() if self.valid_values else self._py_type()
if self.none_ok:
return f'Optional[{typ}]'
return typ
@abstractmethod
def _py_type(self) -> str:
"""An internal version of `.py_type()` so that we don't have to handle `None` everywhere.
This must be implemented by all subclasses of `BaseType`.
"""
raise NotImplementedError
def to_str(self, value: Any) -> str:
"""Get a string from the setting value.
@ -367,6 +390,9 @@ class MappingType(BaseType):
self.valid_values = ValidValues(
*[(key, doc) for (key, (_val, doc)) in self.MAPPING.items()])
def _py_type(self) -> str:
return f'Literal[{", ".join(repr(k) for k in self.MAPPING)}]'
def to_py(self, value: Any) -> Any:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -424,6 +450,9 @@ class String(BaseType):
self.encoding = encoding
self.regex = regex
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -499,6 +528,9 @@ class List(BaseType):
self.valtype = valtype
self.length = length
def _py_type(self) -> str:
return f'list[{self.valtype.py_type()}]'
def get_name(self) -> str:
name = super().get_name()
if self._show_valtype:
@ -597,6 +629,9 @@ class ListOrValue(BaseType):
self.listtype = List(valtype=valtype, none_ok=none_ok, **kwargs)
self.valtype = valtype
def _py_type(self) -> str:
return f'Union[{self.valtype.py_type()}, {self.listtype.py_type()}]'
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):
@ -737,6 +772,15 @@ class Bool(BaseType):
super().__init__(none_ok=none_ok, completions=completions)
self.valid_values = ValidValues('true', 'false', generate_docs=False)
def py_type(self) -> str:
typ = self._py_type()
if self.none_ok:
return f'Optional[{typ}]'
return typ
def _py_type(self) -> str:
return 'bool'
def to_py(self, value: Union[bool, str, None]) -> Optional[bool]:
self._basic_py_validation(value, bool)
assert not isinstance(value, str)
@ -773,6 +817,9 @@ class BoolAsk(Bool):
super().__init__(none_ok=none_ok, completions=completions)
self.valid_values = ValidValues('true', 'false', 'ask')
def _py_type(self) -> str:
return "Union[bool, Literal['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
@ -869,6 +916,9 @@ class Int(_Numeric):
"""Base class for an integer setting."""
def _py_type(self) -> str:
return 'int'
def from_str(self, value: str) -> Optional[int]:
self._basic_str_validation(value)
if not value:
@ -891,6 +941,9 @@ class Float(_Numeric):
"""Base class for a float setting."""
def _py_type(self) -> str:
return 'float'
def from_str(self, value: str) -> Optional[float]:
self._basic_str_validation(value)
if not value:
@ -916,6 +969,9 @@ class Perc(_Numeric):
"""A percentage."""
def _py_type(self) -> str:
return 'Union[float, int, str]'
def to_py(
self,
value: Union[float, int, str, _UnsetNone]
@ -978,6 +1034,9 @@ class PercOrInt(_Numeric):
raise ValueError("minperc ({}) needs to be <= maxperc "
"({})!".format(self.minperc, self.maxperc))
def _py_type(self) -> str:
return 'Union[int, str]'
def from_str(self, value: str) -> Union[None, str, int]:
self._basic_str_validation(value)
if not value:
@ -1040,6 +1099,9 @@ class Command(BaseType):
invalid commands (in bindings/aliases) fail when used.
"""
def _py_type(self) -> str:
return 'str'
def complete(self) -> _Completions:
if self._completions is not None:
return self._completions
@ -1094,6 +1156,9 @@ class QtColor(BaseType):
* `hsv(h, s, v)` / `hsva(h, s, v, a)` (values 0-255, hue 0-359)
"""
def _py_type(self) -> str:
return 'str'
def _parse_value(self, kind: str, val: str) -> int:
try:
return int(val)
@ -1168,6 +1233,9 @@ class QssColor(BaseType):
under ``Gradient''
"""
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1211,6 +1279,9 @@ class FontBase(BaseType):
)* # 0-inf size/weight/style tags
(?P<family>.+) # mandatory font family""", re.VERBOSE)
def _py_type(self) -> str:
return 'str'
@classmethod
def set_defaults(cls, default_family: list[str], default_size: str) -> None:
"""Make sure default_family/default_size are available.
@ -1313,6 +1384,9 @@ class Regex(BaseType):
operator.or_,
(getattr(re, flag.strip()) for flag in flags.split(' | ')))
def _py_type(self) -> str:
return 'Union[str, re.Pattern[str]]'
def _compile_regex(self, pattern: str) -> Pattern[str]:
"""Check if the given regex is valid.
@ -1385,6 +1459,9 @@ class Dict(BaseType):
self.fixed_keys = fixed_keys
self.required_keys = required_keys
def _py_type(self) -> str:
return f"Mapping[{self.keytype.py_type()}, {self.valtype.py_type()}]"
def _validate_keys(self, value: dict) -> None:
if (self.fixed_keys is not None and not
set(value.keys()).issubset(self.fixed_keys)):
@ -1484,6 +1561,9 @@ class File(BaseType):
super().__init__(none_ok=none_ok, completions=completions)
self.required = required
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1516,6 +1596,9 @@ class Directory(BaseType):
"""A directory on the local filesystem."""
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1560,6 +1643,9 @@ class FormatString(BaseType):
self.encoding = encoding
self._completions = completions
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1643,6 +1729,11 @@ class Proxy(BaseType):
others_permitted=True,
)
def _py_type(self) -> str:
# this should always be set as it is assigned in __init__
assert self.valid_values is not None
return f'Union[{self.valid_values.py_type()}, str]'
def to_py(
self,
value: _StrUnset
@ -1689,6 +1780,9 @@ class SearchEngineUrl(BaseType):
"""A search engine URL."""
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1719,6 +1813,9 @@ class FuzzyUrl(BaseType):
"""A URL which gets interpreted as search if needed."""
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> Union[QUrl, _UnsetNone]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1777,6 +1874,9 @@ class Encoding(BaseType):
"""Setting for a python encoding."""
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1842,6 +1942,9 @@ class Url(BaseType):
"""A URL as a string."""
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> Union[_UnsetNone, QUrl]:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1860,6 +1963,9 @@ class SessionName(BaseType):
"""The name of a session."""
def _py_type(self) -> str:
return 'str'
def to_py(self, value: _StrUnset) -> _StrUnsetNone:
self._basic_py_validation(value, str)
if isinstance(value, usertypes.Unset):
@ -1976,6 +2082,9 @@ class Key(BaseType):
"""Make sure key sequences are always normalized."""
return str(keyutils.KeySequence.parse(value))
def _py_type(self) -> str:
return 'str'
def to_py(
self,
value: _StrUnset
@ -2001,6 +2110,9 @@ class UrlPattern(BaseType):
for the allowed syntax.
"""
def _py_type(self) -> str:
return 'str'
def to_py(
self,
value: _StrUnset

View File

@ -363,7 +363,7 @@ def change_console_formatter(level: int) -> None:
assert isinstance(old_formatter, JSONFormatter), old_formatter
def init_from_config(conf: 'configmodule.ConfigContainer') -> None:
def init_from_config(conf: 'configmodule.ConfigContainerInternal') -> None:
"""Initialize logging settings from the config.
init_log is called before the config module is initialized, so config-based

View File

@ -0,0 +1,152 @@
"""This script auto-generates the `qutebrowser/config/configcontainer.py` file."""
import pathlib
import textwrap
from typing import TYPE_CHECKING, NoReturn, Union
from collections.abc import Mapping, Iterator
from qutebrowser.config import configdata
from qutebrowser.config.configdata import Option
if TYPE_CHECKING:
from typing_extensions import assert_never
else:
def assert_never(value) -> NoReturn:
raise AssertionError(f"Expected code to be unreachable, but got: {repr(value)}")
NestedConfig = dict[str, Union[Option, "NestedConfig"]]
def make_nested_config(config: Mapping[str, Option]) -> NestedConfig:
"""The original configdata.yml defines nested keys using `.`s in a flat tree.
This function returns a new dict where options are grouped and nested, e.g.
`'auto_save.session': Option(...)` -> `{'auto_save': {'session': Option(...)}}`
"""
result = {}
for key, value in config.items():
parts = key.split(".")
current = result
for part in parts[:-1]:
current = current.setdefault(part, {})
current[parts[-1]] = value
return result
def generate_config_types(config_data: Mapping[str, Option]) -> Iterator[str]:
"""Generate the `ConfigContainer` dataclass and all nested types."""
yield "# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org>"
yield "#"
yield "# SPDX-License-Identifier: GPL-3.0-or-later"
yield ""
yield "# DO NOT EDIT THIS FILE DIRECTLY!"
yield "# It is autogenerated by running:"
yield "# $ python3 scripts/dev/src2asciidoc.py"
yield "# vim: readonly:"
yield ""
yield triple_quote(
textwrap.dedent("""
This file defines static types for the `c` variable in `config.py`.
This is auto-generated from the `scripts/dev/generate_config_types.py` file.
It is not intended to be used at runtime.
Example usage:
```py
from qutebrowser.api.configpy import c, config
```
""").lstrip()
)
yield ""
yield "# pylint: disable=line-too-long, invalid-name"
yield ""
yield "import re"
yield "from dataclasses import dataclass"
yield "from collections.abc import Mapping"
yield "from typing import Optional, Union, Literal"
yield ""
yield ""
def generate_class(
class_name: str,
config: NestedConfig,
*,
indent: str = "",
description: 'str | None' = None,
) -> Iterator[str]:
yield f"{indent}@dataclass"
yield f"{indent}class {class_name}:"
if description is not None:
yield f"{indent} {triple_quote(description)}"
for key, value in config.items():
if isinstance(value, Option):
type_hint = value.typ.py_type()
if value.default is None:
yield f"{indent} {key}: Optional[{type_hint}]"
else:
yield f"{indent} {key}: {type_hint}"
if value.description:
lines = value.description.split("\n")
description = "\n\n".join(
[
line if i == 0 else f"{indent} {line}"
for i, line in enumerate(lines)
]
)
if not description.endswith("\n") and len(lines) > 1:
description += f"\n{indent} "
yield f"{indent} {triple_quote(description)}\n"
elif isinstance(value, dict):
nested_class_name = "_" + snake_to_camel(key)
yield f"{indent} {key}: '{nested_class_name}'"
yield from generate_class(
nested_class_name, value, indent=indent + " "
)
else:
assert_never(value)
nested_config = make_nested_config(config_data)
yield from generate_class(
"ConfigContainer",
nested_config,
description="Type for the `c` variable in `config.py`.",
)
def snake_to_camel(name: str) -> str:
return "".join(word.capitalize() for word in name.split("_"))
def triple_quote(v: str) -> str:
"""surround the given string with trible double quotes."""
# some option descriptions use `\+` which isn't a valid escape sequence
# in python docstrings, so just escape it.
return '"""' + v.replace(r"\+", r"\\+") + '"""'
def main():
configdata.init()
generated_code = "\n".join(generate_config_types(configdata.DATA))
output_file = (
pathlib.Path(__file__).parent.parent.parent
/ "qutebrowser"
/ "config"
/ "configcontainer_types.py"
)
output_file.write_text(generated_code)
print(f"Config types have been written to {output_file.resolve()}")
if __name__ == "__main__":
main()

View File

@ -178,7 +178,8 @@ def run(files):
vult = vulture.Vulture(verbose=False)
vult.scavenge(
files + [whitelist_file.name],
exclude=["qutebrowser/qt/_core_pyqtproperty.py"],
exclude=["qutebrowser/qt/_core_pyqtproperty.py",
"qutebrowser/config/configcontainer_types.py"],
)
os.remove(whitelist_file.name)

View File

@ -28,6 +28,7 @@ from qutebrowser.config import configdata, configtypes
from qutebrowser.utils import docutils, usertypes
from qutebrowser.misc import objects
from scripts import asciidoc2html, utils
from scripts.dev import generate_config_types
FILE_HEADER = """
// DO NOT EDIT THIS FILE DIRECTLY!
@ -572,6 +573,8 @@ def main():
regenerate_cheatsheet()
if '--html' in sys.argv:
asciidoc2html.main()
print("Generating config.py types...")
generate_config_types.main()
if __name__ == '__main__':

View File

@ -321,7 +321,7 @@ def config_stub(stubs, monkeypatch, configdata_init, yaml_config_stub, qapp):
conf = config.Config(yaml_config=yaml_config_stub)
monkeypatch.setattr(config, 'instance', conf)
container = config.ConfigContainer(conf)
container = config.ConfigContainerInternal(conf)
monkeypatch.setattr(config, 'val', container)
monkeypatch.setattr(configapi, 'val', container)

View File

@ -751,7 +751,7 @@ class TestContainer:
@pytest.fixture
def container(self, config_stub):
return config.ConfigContainer(config_stub)
return config.ConfigContainerInternal(config_stub)
def test_getattr_invalid_private(self, container):
"""Make sure an invalid _attribute doesn't try getting a container."""
@ -813,4 +813,4 @@ class TestContainer:
pattern = urlmatch.UrlPattern('https://example.com/')
with pytest.raises(TypeError,
match="Can't use pattern without configapi!"):
config.ConfigContainer(config_stub, pattern=pattern)
config.ConfigContainerInternal(config_stub, pattern=pattern)

View File

@ -1007,6 +1007,28 @@ class TestConfigPy:
'assert directory.exists()')
confpy.read()
def test_c_import(self, confpy):
confpy.write('from qutebrowser.api.configpy import c',
'c.colors.hints.bg = "red"')
confpy.read()
assert config.instance.get_obj('colors.hints.bg') == 'red'
def test_config_import(self, confpy):
confpy.write('from qutebrowser.api.configpy import config',
'config.set("colors.hints.bg", "red")')
confpy.read()
assert config.instance.get_obj('colors.hints.bg') == 'red'
def test_c_import_id(self, confpy):
confpy.write('from qutebrowser.api import configpy',
'assert configpy.c is c')
confpy.read()
def test_config_import_id(self, confpy):
confpy.write('from qutebrowser.api import configpy',
'assert configpy.config is config')
confpy.read()
@pytest.mark.parametrize('line', [
'c.colors.hints.bg = "red"',
'config.set("colors.hints.bg", "red")',
@ -1426,6 +1448,8 @@ class TestConfigPyWriter:
# qute://help/configuring.html
# qute://help/settings.html
from qutebrowser.api.configpy import c, config
# Change the argument to True to still load settings configured via autoconfig.yml
config.load_autoconfig(False)

View File

@ -248,6 +248,9 @@ class TestAll:
assert converted == s
def test_py_type_is_defined(self, klass):
assert isinstance(klass().py_type(), str)
def test_none_ok_true(self, klass):
"""Test None and empty string values with none_ok=True."""
typ = klass(none_ok=True)
@ -262,6 +265,7 @@ class TestAll:
assert typ.from_str('') is None
assert typ.to_py(None) == to_py_expected
assert typ.to_str(None) == ''
assert 'Optional' in typ.py_type()
@pytest.mark.parametrize('method, value', [
('from_str', ''),
@ -558,6 +562,9 @@ class FromObjType(configtypes.BaseType):
def from_obj(self, value):
return int(value)
def _py_type(self) -> str:
return 'Any'
def to_py(self, value):
return value