1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net> 4 5 6import inspect 7import os 8import pprint 9import re 10import textwrap 11from typing import ( 12 Any, Callable, Dict, Iterator, List, Set, Tuple, Union, get_type_hints 13) 14 15from kitty.conf.types import Definition, MultiOption, Option, unset 16 17 18def chunks(lst: List, n: int) -> Iterator[List]: 19 for i in range(0, len(lst), n): 20 yield lst[i:i + n] 21 22 23def atoi(text: str) -> str: 24 return f'{int(text):08d}' if text.isdigit() else text 25 26 27def natural_keys(text: str) -> Tuple[str, ...]: 28 return tuple(atoi(c) for c in re.split(r'(\d+)', text)) 29 30 31def generate_class(defn: Definition, loc: str) -> Tuple[str, str]: 32 class_lines: List[str] = [] 33 tc_lines: List[str] = [] 34 a = class_lines.append 35 t = tc_lines.append 36 a('class Options:') 37 t('class Parser:') 38 choices = {} 39 imports: Set[Tuple[str, str]] = set() 40 tc_imports: Set[Tuple[str, str]] = set() 41 42 def type_name(x: type) -> str: 43 ans = x.__name__ 44 if x.__module__ and x.__module__ != 'builtins': 45 imports.add((x.__module__, x.__name__)) 46 return ans 47 48 def option_type_as_str(x: Any) -> str: 49 if hasattr(x, '__name__'): 50 return type_name(x) 51 ans = repr(x) 52 ans = ans.replace('NoneType', 'None') 53 return ans 54 55 def option_type_data(option: Union[Option, MultiOption]) -> Tuple[Callable, str]: 56 func = option.parser_func 57 if func.__module__ == 'builtins': 58 return func, func.__name__ 59 th = get_type_hints(func) 60 rettype = th['return'] 61 typ = option_type_as_str(rettype) 62 if isinstance(option, MultiOption): 63 typ = typ[typ.index('[') + 1:-1] 64 typ = typ.replace('Tuple', 'Dict', 1) 65 return func, typ 66 67 is_mutiple_vars = {} 68 option_names = set() 69 color_table = list(map(str, range(256))) 70 71 def parser_function_declaration(option_name: str) -> None: 72 t('') 73 t(f' def {option_name}(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:') 74 75 for option in sorted(defn.iter_all_options(), key=lambda a: natural_keys(a.name)): 76 option_names.add(option.name) 77 parser_function_declaration(option.name) 78 if isinstance(option, MultiOption): 79 mval: Dict[str, Dict[str, Any]] = {'macos': {}, 'linux': {}, '': {}} 80 func, typ = option_type_data(option) 81 for val in option: 82 if val.add_to_default: 83 gr = mval[val.only] 84 for k, v in func(val.defval_as_str): 85 gr[k] = v 86 is_mutiple_vars[option.name] = typ, mval 87 sig = inspect.signature(func) 88 tc_imports.add((func.__module__, func.__name__)) 89 if len(sig.parameters) == 1: 90 t(f' for k, v in {func.__name__}(val):') 91 t(f' ans["{option.name}"][k] = v') 92 else: 93 t(f' for k, v in {func.__name__}(val, ans["{option.name}"]):') 94 t(f' ans["{option.name}"][k] = v') 95 continue 96 97 if option.choices: 98 typ = 'typing.Literal[{}]'.format(', '.join(repr(x) for x in option.choices)) 99 ename = f'choices_for_{option.name}' 100 choices[ename] = typ 101 typ = ename 102 func = str 103 elif defn.has_color_table and option.is_color_table_color: 104 func, typ = option_type_data(option) 105 t(f' ans[{option.name!r}] = {func.__name__}(val)') 106 tc_imports.add((func.__module__, func.__name__)) 107 cnum = int(option.name[5:]) 108 color_table[cnum] = '0x{:06x}'.format(func(option.defval_as_string).__int__()) 109 continue 110 else: 111 func, typ = option_type_data(option) 112 try: 113 params = inspect.signature(func).parameters 114 except Exception: 115 params = {} 116 if 'dict_with_parse_results' in params: 117 t(f' {func.__name__}(val, ans)') 118 else: 119 t(f' ans[{option.name!r}] = {func.__name__}(val)') 120 if func.__module__ != 'builtins': 121 tc_imports.add((func.__module__, func.__name__)) 122 123 defval = repr(func(option.defval_as_string)) 124 if option.macos_defval is not unset: 125 md = repr(func(option.macos_defval)) 126 defval = f'{md} if is_macos else {defval}' 127 imports.add(('kitty.constants', 'is_macos')) 128 a(f' {option.name}: {typ} = {defval}') 129 if option.choices: 130 t(' val = val.lower()') 131 t(f' if val not in self.choices_for_{option.name}:') 132 t(f' raise ValueError(f"The value {{val}} is not a valid choice for {option.name}")') 133 t(f' ans["{option.name}"] = val') 134 t('') 135 t(f' choices_for_{option.name} = frozenset({option.choices!r})') 136 137 for option_name, (typ, mval) in is_mutiple_vars.items(): 138 a(f' {option_name}: {typ} = ' '{}') 139 140 for parser, aliases in defn.deprecations.items(): 141 for alias in aliases: 142 parser_function_declaration(alias) 143 tc_imports.add((parser.__module__, parser.__name__)) 144 t(f' {parser.__name__}({alias!r}, val, ans)') 145 146 action_parsers = {} 147 148 def resolve_import(ftype: str) -> str: 149 if '.' in ftype: 150 fmod, ftype = ftype.rpartition('.')[::2] 151 else: 152 fmod = f'{loc}.options.utils' 153 imports.add((fmod, ftype)) 154 return ftype 155 156 for aname, action in defn.actions.items(): 157 option_names.add(aname) 158 action_parsers[aname] = func = action.parser_func 159 th = get_type_hints(func) 160 rettype = th['return'] 161 typ = option_type_as_str(rettype) 162 typ = typ[typ.index('[') + 1:-1] 163 a(f' {aname}: typing.List[{typ}] = []') 164 for imp in action.imports: 165 resolve_import(imp) 166 for fname, ftype in action.fields.items(): 167 ftype = resolve_import(ftype) 168 a(f' {fname}: {ftype} = ' '{}') 169 parser_function_declaration(aname) 170 t(f' for k in {func.__name__}(val):') 171 t(f' ans[{aname!r}].append(k)') 172 tc_imports.add((func.__module__, func.__name__)) 173 174 if defn.has_color_table: 175 imports.add(('array', 'array')) 176 a(' color_table: array = array("L", (') 177 for grp in chunks(color_table, 8): 178 a(' ' + ', '.join(grp) + ',') 179 a(' ))') 180 181 a(' config_paths: typing.Tuple[str, ...] = ()') 182 a(' config_overrides: typing.Tuple[str, ...] = ()') 183 a('') 184 a(' def __init__(self, options_dict: typing.Optional[typing.Dict[str, typing.Any]] = None) -> None:') 185 if defn.has_color_table: 186 a(' self.color_table = array(self.color_table.typecode, self.color_table)') 187 a(' if options_dict is not None:') 188 a(' null = object()') 189 a(' for key in option_names:') 190 a(' val = options_dict.get(key, null)') 191 a(' if val is not null:') 192 a(' setattr(self, key, val)') 193 194 a('') 195 a(' @property') 196 a(' def _fields(self) -> typing.Tuple[str, ...]:') 197 a(' return option_names') 198 199 a('') 200 a(' def __iter__(self) -> typing.Iterator[str]:') 201 a(' return iter(self._fields)') 202 203 a('') 204 a(' def __len__(self) -> int:') 205 a(' return len(self._fields)') 206 207 a('') 208 a(' def _copy_of_val(self, name: str) -> typing.Any:') 209 a(' ans = getattr(self, name)') 210 a(' if isinstance(ans, dict):\n ans = ans.copy()') 211 a(' elif isinstance(ans, list):\n ans = ans[:]') 212 a(' return ans') 213 214 a('') 215 a(' def _asdict(self) -> typing.Dict[str, typing.Any]:') 216 a(' return {k: self._copy_of_val(k) for k in self}') 217 218 a('') 219 a(' def _replace(self, **kw: typing.Any) -> "Options":') 220 a(' ans = Options()') 221 a(' for name in self:') 222 a(' setattr(ans, name, self._copy_of_val(name))') 223 a(' for name, val in kw.items():') 224 a(' setattr(ans, name, val)') 225 a(' return ans') 226 227 a('') 228 a(' def __getitem__(self, key: typing.Union[int, str]) -> typing.Any:') 229 a(' k = option_names[key] if isinstance(key, int) else key') 230 a(' try:') 231 a(' return getattr(self, k)') 232 a(' except AttributeError:') 233 a(' pass') 234 a(' raise KeyError(f"No option named: {k}")') 235 236 if defn.has_color_table: 237 a('') 238 a(' def __getattr__(self, key: str) -> typing.Any:') 239 a(' if key.startswith("color"):') 240 a(' q = key[5:]') 241 a(' if q.isdigit():') 242 a(' k = int(q)') 243 a(' if 0 <= k <= 255:') 244 a(' x = self.color_table[k]') 245 a(' return Color((x >> 16) & 255, (x >> 8) & 255, x & 255)') 246 a(' raise AttributeError(key)') 247 a('') 248 a(' def __setattr__(self, key: str, val: typing.Any) -> typing.Any:') 249 a(' if key.startswith("color"):') 250 a(' q = key[5:]') 251 a(' if q.isdigit():') 252 a(' k = int(q)') 253 a(' if 0 <= k <= 255:') 254 a(' self.color_table[k] = int(val)') 255 a(' return') 256 a(' object.__setattr__(self, key, val)') 257 258 a('') 259 a('') 260 a('defaults = Options()') 261 for option_name, (typ, mval) in is_mutiple_vars.items(): 262 a(f'defaults.{option_name} = {mval[""]!r}') 263 if mval['macos']: 264 imports.add(('kitty.constants', 'is_macos')) 265 a('if is_macos:') 266 a(f' defaults.{option_name}.update({mval["macos"]!r}') 267 if mval['macos']: 268 imports.add(('kitty.constants', 'is_macos')) 269 a('if not is_macos:') 270 a(f' defaults.{option_name}.update({mval["linux"]!r}') 271 272 for aname, func in action_parsers.items(): 273 a(f'defaults.{aname} = [') 274 only: Dict[str, List[Tuple[str, Callable]]] = {} 275 for sc in defn.iter_all_maps(aname): 276 if not sc.add_to_default: 277 continue 278 text = sc.parseable_text 279 if sc.only: 280 only.setdefault(sc.only, []).append((text, func)) 281 else: 282 for val in func(text): 283 a(f' # {sc.name}') 284 a(f' {val!r},') 285 a(']') 286 if only: 287 imports.add(('kitty.constants', 'is_macos')) 288 for cond, items in only.items(): 289 cond = 'is_macos' if cond == 'macos' else 'not is_macos' 290 a(f'if {cond}:') 291 for (text, func) in items: 292 for val in func(text): 293 a(f' defaults.{aname}.append({val!r})') 294 295 t('') 296 t('') 297 t('def create_result_dict() -> typing.Dict[str, typing.Any]:') 298 t(' return {') 299 for oname in is_mutiple_vars: 300 t(f' {oname!r}: {{}},') 301 for aname in defn.actions: 302 t(f' {aname!r}: [],') 303 t(' }') 304 305 t('') 306 t('') 307 t(f'actions = frozenset({tuple(defn.actions)!r})') 308 t('') 309 t('') 310 t('def merge_result_dicts(defaults: typing.Dict[str, typing.Any], vals: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:') 311 t(' ans = {}') 312 t(' for k, v in defaults.items():') 313 t(' if isinstance(v, dict):') 314 t(' ans[k] = merge_dicts(v, vals.get(k, {}))') 315 t(' elif k in actions:') 316 t(' ans[k] = v + vals.get(k, [])') 317 t(' else:') 318 t(' ans[k] = vals.get(k, v)') 319 t(' return ans') 320 tc_imports.add(('kitty.conf.utils', 'merge_dicts')) 321 322 t('') 323 t('') 324 t('parser = Parser()') 325 t('') 326 t('') 327 t('def parse_conf_item(key: str, val: str, ans: typing.Dict[str, typing.Any]) -> bool:') 328 t(' func = getattr(parser, key, None)') 329 t(' if func is not None:') 330 t(' func(val, ans)') 331 t(' return True') 332 t(' return False') 333 334 preamble = ['# generated by gen-config.py DO NOT edit', '# vim:fileencoding=utf-8', ''] 335 a = preamble.append 336 337 def output_imports(imports: Set, add_module_imports: bool = True) -> None: 338 a('import typing') 339 seen_mods = {'typing'} 340 mmap: Dict[str, List[str]] = {} 341 for mod, name in imports: 342 mmap.setdefault(mod, []).append(name) 343 for mod in sorted(mmap): 344 names = sorted(mmap[mod]) 345 lines = textwrap.wrap(', '.join(names), 100) 346 if len(lines) == 1: 347 s = lines[0] 348 else: 349 s = '\n '.join(lines) 350 s = f'(\n {s}\n)' 351 a(f'from {mod} import {s}') 352 if add_module_imports and mod not in seen_mods and mod != s: 353 a(f'import {mod}') 354 seen_mods.add(mod) 355 356 output_imports(imports) 357 a('') 358 if choices: 359 a('if typing.TYPE_CHECKING:') 360 for name, cdefn in choices.items(): 361 a(f' {name} = {cdefn}') 362 a('else:') 363 for name in choices: 364 a(f' {name} = str') 365 366 a('') 367 a('option_names = ( # {{''{') 368 a(' ' + pprint.pformat(tuple(sorted(option_names, key=natural_keys)))[1:] + ' # }}''}') 369 class_def = '\n'.join(preamble + ['', ''] + class_lines) 370 371 preamble = ['# generated by gen-config.py DO NOT edit', '# vim:fileencoding=utf-8', ''] 372 a = preamble.append 373 output_imports(tc_imports, False) 374 375 return class_def, '\n'.join(preamble + ['', ''] + tc_lines) 376 377 378def generate_c_conversion(loc: str, ctypes: List[Option]) -> str: 379 lines: List[str] = [] 380 basic_converters = { 381 'int': 'PyLong_AsLong', 'uint': 'PyLong_AsUnsignedLong', 'bool': 'PyObject_IsTrue', 382 'float': 'PyFloat_AsFloat', 'double': 'PyFloat_AsDouble', 383 'time': 'parse_s_double_to_monotonic_t', 'time-ms': 'parse_ms_long_to_monotonic_t' 384 } 385 386 for opt in ctypes: 387 lines.append('') 388 lines.append(f'static void\nconvert_from_python_{opt.name}(PyObject *val, Options *opts) ''{') 389 is_special = opt.ctype.startswith('!') 390 if is_special: 391 func = opt.ctype[1:] 392 lines.append(f' {func}(val, opts);') 393 else: 394 func = basic_converters.get(opt.ctype, opt.ctype) 395 lines.append(f' opts->{opt.name} = {func}(val);') 396 lines.append('}') 397 lines.append('') 398 lines.append(f'static void\nconvert_from_opts_{opt.name}(PyObject *py_opts, Options *opts) ''{') 399 lines.append(f' PyObject *ret = PyObject_GetAttrString(py_opts, "{opt.name}");') 400 lines.append(' if (ret == NULL) return;') 401 lines.append(f' convert_from_python_{opt.name}(ret, opts);') 402 lines.append(' Py_DECREF(ret);') 403 lines.append('}') 404 405 lines.append('') 406 lines.append('static bool\nconvert_opts_from_python_opts(PyObject *py_opts, Options *opts) ''{') 407 for opt in ctypes: 408 lines.append(f' convert_from_opts_{opt.name}(py_opts, opts);') 409 lines.append(' if (PyErr_Occurred()) return false;') 410 lines.append(' return true;') 411 lines.append('}') 412 413 preamble = ['// generated by gen-config.py DO NOT edit', '// vim:fileencoding=utf-8', '#pragma once', '#include "to-c.h"'] 414 return '\n'.join(preamble + ['', ''] + lines) 415 416 417def write_output(loc: str, defn: Definition) -> None: 418 cls, tc = generate_class(defn, loc) 419 with open(os.path.join(*loc.split('.'), 'options', 'types.py'), 'w') as f: 420 f.write(cls + '\n') 421 with open(os.path.join(*loc.split('.'), 'options', 'parse.py'), 'w') as f: 422 f.write(tc + '\n') 423 ctypes = [] 424 for opt in defn.root_group.iter_all_non_groups(): 425 if isinstance(opt, Option) and opt.ctype: 426 ctypes.append(opt) 427 if ctypes: 428 c = generate_c_conversion(loc, ctypes) 429 with open(os.path.join(*loc.split('.'), 'options', 'to-c-generated.h'), 'w') as f: 430 f.write(c + '\n') 431 432 433def main() -> None: 434 # To use run it as: 435 # kitty +runpy 'from kitty.conf.generate import main; main()' /path/to/kitten/file.py 436 import importlib 437 import sys 438 439 from kittens.runner import path_to_custom_kitten, resolved_kitten 440 from kitty.constants import config_dir 441 442 kitten = sys.argv[-1] 443 if not kitten.endswith('.py'): 444 kitten += '.py' 445 kitten = resolved_kitten(kitten) 446 path = os.path.realpath(path_to_custom_kitten(config_dir, kitten)) 447 if not os.path.dirname(path): 448 raise SystemExit(f'No custom kitten named {kitten} found') 449 sys.path.insert(0, os.path.dirname(path)) 450 package_name = os.path.basename(os.path.dirname(path)) 451 m = importlib.import_module('kitten_options_definition') 452 defn = getattr(m, 'definition') 453 loc = package_name 454 cls, tc = generate_class(defn, loc) 455 with open(os.path.join(os.path.dirname(path), 'kitten_options_types.py'), 'w') as f: 456 f.write(cls + '\n') 457 with open(os.path.join(os.path.dirname(path), 'kitten_options_parse.py'), 'w') as f: 458 f.write(tc + '\n') 459