kitty/kitty/conf/generate.py
2023-01-09 17:28:35 +05:30

471 lines
18 KiB
Python

#!/usr/bin/env python
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
import inspect
import os
import pprint
import re
import textwrap
from typing import Any, Callable, Dict, Iterator, List, Set, Tuple, Union, get_type_hints
from kitty.conf.types import Definition, MultiOption, Option, unset
from kitty.types import _T
def chunks(lst: List[_T], n: int) -> Iterator[List[_T]]:
for i in range(0, len(lst), n):
yield lst[i:i + n]
def atoi(text: str) -> str:
return f'{int(text):08d}' if text.isdigit() else text
def natural_keys(text: str) -> Tuple[str, ...]:
return tuple(atoi(c) for c in re.split(r'(\d+)', text))
def generate_class(defn: Definition, loc: str) -> Tuple[str, str]:
class_lines: List[str] = []
tc_lines: List[str] = []
a = class_lines.append
t = tc_lines.append
a('class Options:')
t('class Parser:')
choices = {}
imports: Set[Tuple[str, str]] = set()
tc_imports: Set[Tuple[str, str]] = set()
ki_imports: 're.Pattern[str]' = re.compile(r'\b((?:kittens|kitty).+?)[,\]]')
def option_type_as_str(x: Any) -> str:
needs_import = False
if type(x) is type:
ans = x.__name__
needs_import = True
else:
ans = repr(x)
ans = ans.replace('NoneType', 'None')
if needs_import and getattr(x, '__module__', None) and x.__module__ not in ('builtins', 'typing'):
imports.add((x.__module__, x.__name__))
return ans
def option_type_data(option: Union[Option, MultiOption]) -> Tuple[Callable[[Any], Any], str]:
func = option.parser_func
if func.__module__ == 'builtins':
return func, func.__name__
th = get_type_hints(func)
rettype = th['return']
typ = option_type_as_str(rettype)
if isinstance(option, MultiOption):
typ = typ[typ.index('[') + 1:-1]
typ = typ.replace('Tuple', 'Dict', 1)
kq = ki_imports.search(typ)
if kq is not None:
kqi = kq.group(1)
kqim, kqii = kqi.rsplit('.', 1)
imports.add((kqim, ''))
return func, typ
is_mutiple_vars = {}
option_names = set()
color_table = list(map(str, range(256)))
def parser_function_declaration(option_name: str) -> None:
t('')
t(f' def {option_name}(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:')
for option in sorted(defn.iter_all_options(), key=lambda a: natural_keys(a.name)):
option_names.add(option.name)
parser_function_declaration(option.name)
if isinstance(option, MultiOption):
mval: Dict[str, Dict[str, Any]] = {'macos': {}, 'linux': {}, '': {}}
func, typ = option_type_data(option)
for val in option:
if val.add_to_default:
gr = mval[val.only]
for k, v in func(val.defval_as_str):
gr[k] = v
is_mutiple_vars[option.name] = typ, mval
sig = inspect.signature(func)
tc_imports.add((func.__module__, func.__name__))
if len(sig.parameters) == 1:
t(f' for k, v in {func.__name__}(val):')
t(f' ans["{option.name}"][k] = v')
else:
t(f' for k, v in {func.__name__}(val, ans["{option.name}"]):')
t(f' ans["{option.name}"][k] = v')
continue
if option.choices:
typ = 'typing.Literal[{}]'.format(', '.join(repr(x) for x in option.choices))
ename = f'choices_for_{option.name}'
choices[ename] = typ
typ = ename
func = str
elif defn.has_color_table and option.is_color_table_color:
func, typ = option_type_data(option)
t(f' ans[{option.name!r}] = {func.__name__}(val)')
tc_imports.add((func.__module__, func.__name__))
cnum = int(option.name[5:])
color_table[cnum] = f'0x{func(option.defval_as_string).__int__():06x}'
continue
else:
func, typ = option_type_data(option)
try:
params = dict(inspect.signature(func).parameters)
except Exception:
params = {}
if 'dict_with_parse_results' in params:
t(f' {func.__name__}(val, ans)')
else:
t(f' ans[{option.name!r}] = {func.__name__}(val)')
if func.__module__ != 'builtins':
tc_imports.add((func.__module__, func.__name__))
defval_as_obj = func(option.defval_as_string)
if isinstance(defval_as_obj, frozenset):
defval = 'frozenset({' + ', '.join(repr(x) for x in sorted(defval_as_obj)) + '})'
else:
defval = repr(defval_as_obj)
if option.macos_defval is not unset:
md = repr(func(option.macos_defval))
defval = f'{md} if is_macos else {defval}'
imports.add(('kitty.constants', 'is_macos'))
a(f' {option.name}: {typ} = {defval}')
if option.choices:
t(' val = val.lower()')
t(f' if val not in self.choices_for_{option.name}:')
t(f' raise ValueError(f"The value {{val}} is not a valid choice for {option.name}")')
t(f' ans["{option.name}"] = val')
t('')
t(f' choices_for_{option.name} = frozenset({option.choices!r})')
for option_name, (typ, mval) in is_mutiple_vars.items():
a(f' {option_name}: {typ} = ' '{}')
for parser, aliases in defn.deprecations.items():
for alias in aliases:
parser_function_declaration(alias)
tc_imports.add((parser.__module__, parser.__name__))
t(f' {parser.__name__}({alias!r}, val, ans)')
action_parsers = {}
def resolve_import(ftype: str) -> str:
if '.' in ftype:
fmod, ftype = ftype.rpartition('.')[::2]
else:
fmod = f'{loc}.options.utils'
imports.add((fmod, ftype))
return ftype
for aname, action in defn.actions.items():
option_names.add(aname)
action_parsers[aname] = func = action.parser_func
th = get_type_hints(func)
rettype = th['return']
typ = option_type_as_str(rettype)
typ = typ[typ.index('[') + 1:-1]
a(f' {aname}: typing.List[{typ}] = []')
for imp in action.imports:
resolve_import(imp)
for fname, ftype in action.fields.items():
ftype = resolve_import(ftype)
fval = f'{ftype}()' if ftype == 'AliasMap' else '{}'
a(f' {fname}: {ftype} = {fval}')
parser_function_declaration(aname)
t(f' for k in {func.__name__}(val):')
t(f' ans[{aname!r}].append(k)')
tc_imports.add((func.__module__, func.__name__))
if defn.has_color_table:
imports.add(('array', 'array'))
a(' color_table: "array[int]" = array("L", (')
for grp in chunks(color_table, 8):
a(' ' + ', '.join(grp) + ',')
a(' ))')
a(' config_paths: typing.Tuple[str, ...] = ()')
a(' config_overrides: typing.Tuple[str, ...] = ()')
a('')
a(' def __init__(self, options_dict: typing.Optional[typing.Dict[str, typing.Any]] = None) -> None:')
if defn.has_color_table:
a(' self.color_table = array(self.color_table.typecode, self.color_table)')
a(' if options_dict is not None:')
a(' null = object()')
a(' for key in option_names:')
a(' val = options_dict.get(key, null)')
a(' if val is not null:')
a(' setattr(self, key, val)')
a('')
a(' @property')
a(' def _fields(self) -> typing.Tuple[str, ...]:')
a(' return option_names')
a('')
a(' def __iter__(self) -> typing.Iterator[str]:')
a(' return iter(self._fields)')
a('')
a(' def __len__(self) -> int:')
a(' return len(self._fields)')
a('')
a(' def _copy_of_val(self, name: str) -> typing.Any:')
a(' ans = getattr(self, name)')
a(' if isinstance(ans, dict):\n ans = ans.copy()')
a(' elif isinstance(ans, list):\n ans = ans[:]')
a(' return ans')
a('')
a(' def _asdict(self) -> typing.Dict[str, typing.Any]:')
a(' return {k: self._copy_of_val(k) for k in self}')
a('')
a(' def _replace(self, **kw: typing.Any) -> "Options":')
a(' ans = Options()')
a(' for name in self:')
a(' setattr(ans, name, self._copy_of_val(name))')
a(' for name, val in kw.items():')
a(' setattr(ans, name, val)')
a(' return ans')
a('')
a(' def __getitem__(self, key: typing.Union[int, str]) -> typing.Any:')
a(' k = option_names[key] if isinstance(key, int) else key')
a(' try:')
a(' return getattr(self, k)')
a(' except AttributeError:')
a(' pass')
a(' raise KeyError(f"No option named: {k}")')
if defn.has_color_table:
a('')
a(' def __getattr__(self, key: str) -> typing.Any:')
a(' if key.startswith("color"):')
a(' q = key[5:]')
a(' if q.isdigit():')
a(' k = int(q)')
a(' if 0 <= k <= 255:')
a(' x = self.color_table[k]')
a(' return Color((x >> 16) & 255, (x >> 8) & 255, x & 255)')
a(' raise AttributeError(key)')
a('')
a(' def __setattr__(self, key: str, val: typing.Any) -> typing.Any:')
a(' if key.startswith("color"):')
a(' q = key[5:]')
a(' if q.isdigit():')
a(' k = int(q)')
a(' if 0 <= k <= 255:')
a(' self.color_table[k] = int(val)')
a(' return')
a(' object.__setattr__(self, key, val)')
a('')
a('')
a('defaults = Options()')
for option_name, (typ, mval) in is_mutiple_vars.items():
a(f'defaults.{option_name} = {mval[""]!r}')
if mval['macos']:
imports.add(('kitty.constants', 'is_macos'))
a('if is_macos:')
a(f' defaults.{option_name}.update({mval["macos"]!r}')
if mval['macos']:
imports.add(('kitty.constants', 'is_macos'))
a('if not is_macos:')
a(f' defaults.{option_name}.update({mval["linux"]!r}')
for aname, func in action_parsers.items():
a(f'defaults.{aname} = [')
only: Dict[str, List[Tuple[str, Callable[..., Any]]]] = {}
for sc in defn.iter_all_maps(aname):
if not sc.add_to_default:
continue
text = sc.parseable_text
if sc.only:
only.setdefault(sc.only, []).append((text, func))
else:
for val in func(text):
a(f' # {sc.name}')
a(f' {val!r}, ')
a(']')
if only:
imports.add(('kitty.constants', 'is_macos'))
for cond, items in only.items():
cond = 'is_macos' if cond == 'macos' else 'not is_macos'
a(f'if {cond}:')
for (text, parser_func) in items:
for val in parser_func(text):
a(f' defaults.{aname}.append({val!r})')
t('')
t('')
t('def create_result_dict() -> typing.Dict[str, typing.Any]:')
t(' return {')
for oname in is_mutiple_vars:
t(f' {oname!r}: {{}},')
for aname in defn.actions:
t(f' {aname!r}: [],')
t(' }')
t('')
t('')
t(f'actions: typing.FrozenSet[str] = frozenset({tuple(defn.actions)!r})')
t('')
t('')
t('def merge_result_dicts(defaults: typing.Dict[str, typing.Any], vals: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:')
t(' ans = {}')
t(' for k, v in defaults.items():')
t(' if isinstance(v, dict):')
t(' ans[k] = merge_dicts(v, vals.get(k, {}))')
t(' elif k in actions:')
t(' ans[k] = v + vals.get(k, [])')
t(' else:')
t(' ans[k] = vals.get(k, v)')
t(' return ans')
tc_imports.add(('kitty.conf.utils', 'merge_dicts'))
t('')
t('')
t('parser = Parser()')
t('')
t('')
t('def parse_conf_item(key: str, val: str, ans: typing.Dict[str, typing.Any]) -> bool:')
t(' func = getattr(parser, key, None)')
t(' if func is not None:')
t(' func(val, ans)')
t(' return True')
t(' return False')
preamble = ['# generated by gen-config.py DO NOT edit', '']
a = preamble.append
def output_imports(imports: Set[Tuple[str, str]], add_module_imports: bool = True) -> None:
a('# isort: skip_file')
a('import typing')
seen_mods = {'typing'}
mmap: Dict[str, List[str]] = {}
for mod, name in imports:
mmap.setdefault(mod, []).append(name)
for mod in sorted(mmap):
names = list(filter(None, sorted(mmap[mod])))
if names:
lines = textwrap.wrap(', '.join(names), 100)
if len(lines) == 1:
s = lines[0]
else:
s = '\n '.join(lines)
s = f'(\n {s}\n)'
a(f'from {mod} import {s}')
else:
s = ''
if add_module_imports and mod not in seen_mods and mod != s:
a(f'import {mod}')
seen_mods.add(mod)
output_imports(imports)
a('')
if choices:
a('if typing.TYPE_CHECKING:')
for name, cdefn in choices.items():
a(f' {name} = {cdefn}')
a('else:')
for name in choices:
a(f' {name} = str')
a('')
a('option_names = ( # {{''{')
a(' ' + pprint.pformat(tuple(sorted(option_names, key=natural_keys)))[1:] + ' # }}''}')
class_def = '\n'.join(preamble + ['', ''] + class_lines)
preamble = ['# generated by gen-config.py DO NOT edit', '']
a = preamble.append
output_imports(tc_imports, False)
return class_def, '\n'.join(preamble + ['', ''] + tc_lines)
def generate_c_conversion(loc: str, ctypes: List[Union[Option, MultiOption]]) -> str:
lines: List[str] = []
basic_converters = {
'int': 'PyLong_AsLong', 'uint': 'PyLong_AsUnsignedLong', 'bool': 'PyObject_IsTrue',
'float': 'PyFloat_AsFloat', 'double': 'PyFloat_AsDouble',
'time': 'parse_s_double_to_monotonic_t', 'time-ms': 'parse_ms_long_to_monotonic_t'
}
for opt in ctypes:
lines.append('')
lines.append(f'static void\nconvert_from_python_{opt.name}(PyObject *val, Options *opts) ''{')
is_special = opt.ctype.startswith('!')
if is_special:
func = opt.ctype[1:]
lines.append(f' {func}(val, opts);')
else:
func = basic_converters.get(opt.ctype, opt.ctype)
lines.append(f' opts->{opt.name} = {func}(val);')
lines.append('}')
lines.append('')
lines.append(f'static void\nconvert_from_opts_{opt.name}(PyObject *py_opts, Options *opts) ''{')
lines.append(f' PyObject *ret = PyObject_GetAttrString(py_opts, "{opt.name}");')
lines.append(' if (ret == NULL) return;')
lines.append(f' convert_from_python_{opt.name}(ret, opts);')
lines.append(' Py_DECREF(ret);')
lines.append('}')
lines.append('')
lines.append('static bool\nconvert_opts_from_python_opts(PyObject *py_opts, Options *opts) ''{')
for opt in ctypes:
lines.append(f' convert_from_opts_{opt.name}(py_opts, opts);')
lines.append(' if (PyErr_Occurred()) return false;')
lines.append(' return true;')
lines.append('}')
preamble = ['// generated by gen-config.py DO NOT edit', '// vim:fileencoding=utf-8', '#pragma once', '#include "to-c.h"']
return '\n'.join(preamble + ['', ''] + lines)
def write_output(loc: str, defn: Definition) -> None:
cls, tc = generate_class(defn, loc)
with open(os.path.join(*loc.split('.'), 'options', 'types.py'), 'w') as f:
f.write(f'{cls}\n')
with open(os.path.join(*loc.split('.'), 'options', 'parse.py'), 'w') as f:
f.write(f'{tc}\n')
ctypes = []
for opt in defn.root_group.iter_all_non_groups():
if isinstance(opt, (Option, MultiOption)) and opt.ctype:
ctypes.append(opt)
if ctypes:
c = generate_c_conversion(loc, ctypes)
with open(os.path.join(*loc.split('.'), 'options', 'to-c-generated.h'), 'w') as f:
f.write(f'{c}\n')
def main() -> None:
# To use run it as:
# kitty +runpy 'from kitty.conf.generate import main; main()' /path/to/kitten/file.py
import importlib
import sys
from kittens.runner import path_to_custom_kitten, resolved_kitten
from kitty.constants import config_dir
kitten = sys.argv[-1]
if not kitten.endswith('.py'):
kitten += '.py'
kitten = resolved_kitten(kitten)
path = os.path.realpath(path_to_custom_kitten(config_dir, kitten))
if not os.path.dirname(path):
raise SystemExit(f'No custom kitten named {kitten} found')
sys.path.insert(0, os.path.dirname(path))
package_name = os.path.basename(os.path.dirname(path))
m = importlib.import_module('kitten_options_definition')
defn = getattr(m, 'definition')
loc = package_name
cls, tc = generate_class(defn, loc)
with open(os.path.join(os.path.dirname(path), 'kitten_options_types.py'), 'w') as f:
f.write(f'{cls}\n')
with open(os.path.join(os.path.dirname(path), 'kitten_options_parse.py'), 'w') as f:
f.write(f'{tc}\n')