Add generated types for `c` in `config.py`
This commit is contained in:
parent
af835c26ad
commit
421b05ec61
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue