Add generated types for `c` in `config.py`

This commit is contained in:
Robert Craigie 2024-10-25 23:08:15 +01:00
parent af835c26ad
commit 421b05ec61
6 changed files with 2776 additions and 1 deletions

File diff suppressed because it is too large Load Diff

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
@ -294,6 +302,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.
@ -356,6 +379,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):
@ -413,6 +439,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):
@ -488,6 +517,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:
@ -586,6 +618,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):
@ -726,6 +761,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)
@ -762,6 +806,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
@ -858,6 +905,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:
@ -880,6 +930,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:
@ -905,6 +958,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]
@ -967,6 +1023,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:
@ -1029,6 +1088,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
@ -1083,6 +1145,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)
@ -1157,6 +1222,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):
@ -1200,6 +1268,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.
@ -1302,6 +1373,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.
@ -1374,6 +1448,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)):
@ -1473,6 +1550,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):
@ -1505,6 +1585,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):
@ -1549,6 +1632,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):
@ -1632,6 +1718,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
@ -1678,6 +1769,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):
@ -1708,6 +1802,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):
@ -1766,6 +1863,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):
@ -1831,6 +1931,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):
@ -1849,6 +1952,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):
@ -1965,6 +2071,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
@ -1990,6 +2099,9 @@ class UrlPattern(BaseType):
for the allowed syntax.
"""
def _py_type(self) -> str:
return 'str'
def to_py(
self,
value: _StrUnset

View File

@ -0,0 +1,160 @@
"""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 typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from qutebrowser.config.configfiles import ConfigAPI
from qutebrowser.config.configcontainer import ConfigContainer
# note: these expressions aren't executed at runtime
c = cast(ConfigContainer, ...)
config = cast(ConfigAPI, ...)
```
""").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

@ -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