Get basic C cli parsing working

This commit is contained in:
Kovid Goyal 2025-04-27 09:07:19 +05:30
parent 527f30ffcc
commit 62699f6799
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
5 changed files with 187 additions and 101 deletions

View file

@ -4,17 +4,16 @@
import os
import re
import sys
from collections import deque
from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass
from enum import Enum, auto
from re import Match
from typing import Any, TypeVar, Union, cast
from typing import Any, NoReturn, TypeVar, Union, cast
from .cli_stub import CLIOptions
from .conf.utils import resolve_config
from .constants import appname, clear_handled_signals, config_dir, default_pager_for_help, defconf, is_macos, str_version, website_url
from .fast_data_types import wcswidth
from .fast_data_types import parse_cli_from_spec, wcswidth
from .options.types import Options as KittyOpts
from .types import run_once
from .typing import BadLineType, TypedDict
@ -101,9 +100,9 @@ class CompletionSpec:
class OptionDict(TypedDict):
dest: str
name: str
aliases: frozenset[str]
aliases: tuple[str, ...]
help: str
choices: frozenset[str]
choices: tuple[str, ...]
type: str
default: str | None
condition: bool
@ -410,7 +409,7 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe
disabled: OptionSpecSeq = []
mpat = re.compile('([a-z]+)=(.+)')
current_cmd: OptionDict = {
'dest': '', 'aliases': frozenset(), 'help': '', 'choices': frozenset(),
'dest': '', 'aliases': (), 'help': '', 'choices': (),
'type': '', 'condition': False, 'default': None, 'completion': CompletionSpec(), 'name': ''
}
empty_cmd = current_cmd
@ -430,8 +429,8 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe
parts = line.split(' ')
defdest = parts[0][2:].replace('-', '_')
current_cmd = {
'dest': defdest, 'aliases': frozenset(parts), 'help': '',
'choices': frozenset(), 'type': '', 'name': defdest,
'dest': defdest, 'aliases': tuple(parts), 'help': '',
'choices': tuple(), 'type': '', 'name': defdest,
'default': None, 'condition': True, 'completion': CompletionSpec(),
}
state = METADATA
@ -450,7 +449,7 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe
current_cmd['type'] = 'choices'
if current_cmd['type'] != 'choices':
raise ValueError(f'Cannot specify choices for an option of type: {current_cmd["type"]}')
current_cmd['choices'] = frozenset(vals)
current_cmd['choices'] = tuple(vals)
if current_cmd['default'] is None:
current_cmd['default'] = vals[0]
else:
@ -800,18 +799,15 @@ class Options:
self.names_map[name] = opt
self.values_map[name] = defval_for_opt(opt)
def check_for_standard_flag(self, alias: str) -> None:
if alias in ('-h', '--help'):
if self.do_print:
print_help_for_seq(self.seq, self.usage, self.message, self.appname or appname)
raise SystemExit(0)
opt = self.alias_map.get(alias)
if opt is None:
raise SystemExit(f'Unknown option: {emph(alias)}')
if opt['dest'] == 'version':
if self.do_print:
print(version())
raise SystemExit(0)
def handle_help(self) -> NoReturn:
if self.do_print:
print_help_for_seq(self.seq, self.usage, self.message, self.appname or appname)
raise SystemExit(0)
def handle_version(self) -> NoReturn:
if self.do_print:
print(version())
raise SystemExit(0)
def is_bool(self, alias: str) -> bool:
opt = self.alias_map[alias]
@ -854,58 +850,16 @@ class Options:
def parse_cmdline(oc: Options, disabled: OptionSpecSeq, ans: Any, args: list[str] | None = None) -> list[str]:
NORMAL, EXPECTING_ARG = 'NORMAL', 'EXPECTING_ARG'
state = NORMAL
dargs = deque(sys.argv[1:] if args is None else args)
leftover_args: list[str] = []
current_option = None
payload: str | None = None
try:
vals, leftover_args = parse_cli_from_spec(sys.argv[1:] if args is None else args, oc.names_map, oc.values_map)
except Exception as e:
raise SystemExit(str(e))
while dargs:
arg = dargs.popleft()
if state is NORMAL:
if arg.startswith('-'):
is_long_opt = arg.startswith('--')
if is_long_opt and arg == '--':
leftover_args = list(dargs)
break
flag, has_equal, payload = arg.partition('=')
if not has_equal:
payload = None
if is_long_opt:
oc.check_for_standard_flag(flag)
if oc.is_bool(flag):
oc.process_arg(flag, payload)
continue
if not has_equal:
current_option = flag
state = EXPECTING_ARG
continue
oc.process_arg(flag, payload)
else:
letters = flag[1:]
for letter in letters[:-1]:
flag = f'-{letter}'
oc.check_for_standard_flag(flag)
oc.process_arg(flag)
flag = f'-{letters[-1]}'
oc.check_for_standard_flag(flag)
if oc.is_bool(flag) or payload is not None:
oc.process_arg(flag, payload)
else:
current_option = flag
state = EXPECTING_ARG
continue
else:
leftover_args = [arg] + list(dargs)
break
elif current_option is not None:
oc.process_arg(current_option, arg)
current_option, state = None, NORMAL
if state is EXPECTING_ARG:
raise SystemExit(f'An argument is required for the option: {emph(arg)}')
for key, val in oc.values_map.items():
for key, (val, is_seen) in vals.items():
if key == 'version' and is_seen and val:
oc.handle_version()
elif key == 'help' and is_seen and val:
oc.handle_help()
setattr(ans, key, val)
for opt in disabled:
if not isinstance(opt, str):

View file

@ -678,10 +678,12 @@ py_get_config_dir(PyObject *self UNUSED, PyObject *args UNUSED) {
return PyUnicode_FromString("");
}
#include "launcher/cli-parser.h"
static PyMethodDef module_methods[] = {
METHODB(replace_c0_codes_except_nl_space_tab, METH_O),
METHODB(read_file, METH_O),
{"parse_cli_from_spec", parse_cli_from_python_spec, METH_VARARGS, ""},
{"wcwidth", (PyCFunction)wcwidth_wrap, METH_O, ""},
{"expanduser", (PyCFunction)expanduser, METH_O, ""},
{"abspath", (PyCFunction)abspath, METH_O, ""},

View file

@ -2,6 +2,7 @@ import termios
from typing import Any, Callable, Dict, Iterator, List, Literal, NewType, Optional, Tuple, TypedDict, Union, overload
from kitty.boss import Boss
from kitty.cli import OptionDict
from kitty.fonts import VariableData
from kitty.fonts.render import FontObject
from kitty.marks import MarkerFunc
@ -1704,6 +1705,7 @@ def get_clipboard_mime(ct: int, mime: Optional[str], callback: Callable[[bytes],
def run_with_activation_token(func: Callable[[str], None]) -> bool: ...
def make_x11_window_a_dock_window(x11_window_id: int, strut: Tuple[int, int, int, int, int, int, int, int, int, int, int, int]) -> None: ...
def toggle_os_window_visibility(os_window_id: int, visible: Literal[True, False] = ...) -> bool: ...
def parse_cli_from_spec(args: list[str], names_map: dict[str, OptionDict], defval_map: dict[str, Any]) -> tuple[dict[str, tuple[Any, bool]], list[str]]: ...
def layer_shell_config_for_os_window(os_window_id: int) -> dict[str, Any] | None: ...
def set_layer_shell_config(os_window_id: int, cfg: LayerShellConfig) -> bool: ...
def wrapped_kitten_names() -> List[str]: ...

View file

@ -7,12 +7,29 @@
#pragma once
#include "listobject.h"
#include <Python.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#ifndef RAII_ALLOC
static inline void cleanup_decref2(PyObject **p) { Py_CLEAR(*p); }
#define RAII_PyObject(name, initializer) __attribute__((cleanup(cleanup_decref2))) PyObject *name = initializer
static inline void cleanup_free(void *p) { free(*(void**)p); }
#define RAII_ALLOC(type, name, initializer) __attribute__((cleanup(cleanup_free))) type *name = initializer
#endif
static inline void
cleanup_argv(void *p) {
for (char **argv = *(void **)p; *argv; argv++) free(*argv);
free(*(void**)p);
}
#define RAII_ARGV(name, argc) __attribute__((cleanup(cleanup_argv))) char** name = calloc(argc + 1, sizeof(char*))
typedef enum CLIValueType { CLI_VALUE_STRING, CLI_VALUE_BOOL, CLI_VALUE_INT, CLI_VALUE_FLOAT, CLI_VALUE_LIST, CLI_VALUE_CHOICE } CLIValueType;
typedef struct CLIValue {
CLIValueType type;
@ -23,6 +40,7 @@ typedef struct CLIValue {
struct {
const char* * items;
size_t count, capacity;
bool needs_free;
} listval;
} CLIValue;
@ -40,7 +58,7 @@ typedef struct CLIValue {
typedef struct FlagSpec {
CLIValue defval;
const char *dest, *choices, *aliases;
const char *dest;
} FlagSpec;
#define NAME flag_hash
@ -55,7 +73,7 @@ typedef struct CLISpec {
alias_hash alias_map;
flag_hash flag_map;
char **argv; int argc; // leftover args
char err[512];
char err[1024];
} CLISpec;
static void
@ -66,16 +84,21 @@ out_of_memory(int line) {
#define OOM out_of_memory(__LINE__)
static bool
report_unknown_alias(CLISpec *spec, const char *alias) {
snprintf(spec->err, sizeof(spec->err), "Unknown flag: %s use --help", alias);
return false;
static const char*
dest_for_alias(CLISpec *spec, const char *alias) {
alias_hash_itr itr = vt_get(&spec->alias_map, alias);
if (vt_is_end(itr)) {
snprintf(spec->err, sizeof(spec->err), "Unknown flag: %s use --help", alias);
return NULL;
}
return itr.data->val;
}
static bool
is_alias_bool(CLISpec* spec, const char *alias) {
flag_hash_itr itr = vt_get(&spec->flag_map, alias);
if (!itr.data) return report_unknown_alias(spec, alias);
const char *dest = dest_for_alias(spec, alias);
if (!dest) return false;
flag_hash_itr itr = vt_get(&spec->flag_map, dest);
return itr.data->val->defval.type == CLI_VALUE_BOOL;
}
@ -97,8 +120,9 @@ add_list_value(CLISpec *spec, const char *dest, const char *val) {
static bool
process_cli_arg(CLISpec* spec, const char *alias, const char *payload) {
flag_hash_itr itr = vt_get(&spec->flag_map, alias);
if (!itr.data) return report_unknown_alias(spec, alias);
const char *dest = dest_for_alias(spec, alias);
if (!dest) return false;
flag_hash_itr itr = vt_get(&spec->flag_map, dest);
const FlagSpec *flag = itr.data->val;
CLIValue val = {.type=flag->defval.type};
#define streq(q) (strcmp(payload, #q) == 0)
@ -117,11 +141,14 @@ process_cli_arg(CLISpec* spec, const char *alias, const char *payload) {
break;
case CLI_VALUE_CHOICE:
val.strval = NULL;
for (const char* q = flag->choices; q; q++) if (streq(q)) { val.strval = payload; break; }
for (size_t c = 0; c < flag->defval.listval.count; c++) {
if (strcmp(payload, flag->defval.listval.items[c]) == 0) { val.strval = payload; break; }
}
if (!val.strval) {
int n = snprintf(spec->err, sizeof(spec->err), "%s is an invalid value for %s. Valid values are:",
payload[0] ? payload : "<empty>", alias);
for (const char* q = flag->choices; q; q++) n += snprintf(spec->err + n, sizeof(spec->err) - n, " %s,", q);
for (size_t c = 0; c < flag->defval.listval.count; c++)
n += snprintf(spec->err + n, sizeof(spec->err) - n, " %s,", flag->defval.listval.items[c]);
spec->err[n-1] = '.';
return false;
}
@ -146,37 +173,37 @@ process_cli_arg(CLISpec* spec, const char *alias, const char *payload) {
}
static void
alloc_cli_spec(CLISpec *spec, const FlagSpec *flags) {
alloc_cli_spec(CLISpec *spec) {
vt_init(&spec->value_map);
vt_init(&spec->alias_map);
vt_init(&spec->flag_map);
for (const FlagSpec *flag = flags; flag != NULL; flag++) {
if (vt_is_end(vt_insert(&spec->flag_map, flag->dest, flag))) OOM;
for (const char *alias = flag->aliases; alias != NULL; alias++) {
if (vt_is_end(vt_insert(&spec->alias_map, alias, flag->dest))) OOM;
}
}
}
static void
dealloc_cli_value(CLIValue v) {
free((void*)v.listval.items);
if (v.listval.needs_free) free((void*)v.listval.items);
}
static void
dealloc_cli_spec(CLISpec *spec) {
dealloc_cli_spec(void *v) {
CLISpec *spec = v;
value_map_for_loop(&spec->value_map) {
dealloc_cli_value(itr.data->val);
}
flag_map_for_loop(&spec->flag_map) {
dealloc_cli_value(itr.data->val->defval);
}
vt_cleanup(&spec->value_map);
vt_cleanup(&spec->alias_map);
vt_cleanup(&spec->flag_map);
}
#define RAII_CLISpec(name) __attribute__((cleanup(dealloc_cli_spec))) CLISpec name = {0}; alloc_cli_spec(&name)
static bool
parse_cli_loop(CLISpec *spec, int argc, char **argv) { // argv must contain arg1 and beyond
enum { NORMAL, EXPECTING_ARG } state = NORMAL;
spec->argc = 0; spec->argv = NULL;
spec->argc = 0; spec->argv = NULL; spec->err[0] = 0;
char flag[3] = {'-', 0, 0};
const char *current_option = NULL;
for (int i = 0; i < argc; i++) {
@ -239,3 +266,105 @@ parse_cli_loop(CLISpec *spec, int argc, char **argv) { // argv must contain arg
if (state == EXPECTING_ARG) snprintf(spec->err, sizeof(spec->err), "The %s flag must be followed by an argument.", current_option ? current_option : "");
return spec->err[0] == 0;
}
static PyObject*
cli_parse_result_as_python(CLISpec *spec) {
if (PyErr_Occurred()) return NULL;
if (spec->err[0]) {
PyErr_SetString(PyExc_ValueError, spec->err); return NULL;
}
RAII_PyObject(ans, PyDict_New()); if (!ans) return NULL;
flag_map_for_loop(&spec->flag_map) {
const FlagSpec *flag = itr.data->val;
cli_hash_itr i = vt_get(&spec->value_map, flag->dest);
PyObject *is_seen = vt_is_end(i) ? Py_False : Py_True;
const CLIValue *v = is_seen == Py_True ? &i.data->val : &flag->defval;
#define S(fv) { RAII_PyObject(temp, Py_BuildValue("NO", fv, is_seen)); if (!temp) return NULL; \
if (PyDict_SetItemString(ans, flag->dest, temp) != 0) return NULL;}
switch (v->type) {
case CLI_VALUE_BOOL: S(PyBool_FromLong((long)v->boolval)); break;
case CLI_VALUE_STRING: if (v->strval) { S(PyUnicode_FromString(v->strval)); } else { S(Py_NewRef(Py_None)); } break;
case CLI_VALUE_CHOICE: S(PyUnicode_FromString(v->strval)); break;
case CLI_VALUE_INT: S(PyLong_FromLongLong(v->intval)); break;
case CLI_VALUE_FLOAT: S(PyFloat_FromDouble(v->floatval)); break;
case CLI_VALUE_LIST: {
RAII_PyObject(l, PyList_New(v->listval.count)); if (!l) return NULL;
for (size_t i = 0; i < v->listval.count; i++) {
PyObject *x = PyUnicode_FromString(v->listval.items[i]); if (!x) return NULL;
PyList_SET_ITEM(l, i, x);
}
S(Py_NewRef(l));
} break;
}
}
#undef S
RAII_PyObject(leftover_args, PyList_New(spec->argc)); if (!leftover_args) return NULL;
for (int i = 0; i < spec->argc; i++) {
PyObject *t = PyUnicode_FromString(spec->argv[i]);
if (!t) return NULL;
PyList_SET_ITEM(leftover_args, i, t);
}
return Py_BuildValue("OO", ans, leftover_args);
}
static PyObject*
parse_cli_from_python_spec(PyObject *self, PyObject *args) {
(void)self; PyObject *pyargs, *names_map, *defval_map;
if (!PyArg_ParseTuple(args, "O!O!O!", &PyList_Type, &pyargs, &PyDict_Type, &names_map, &PyDict_Type, &defval_map)) return NULL;
int argc = PyList_GET_SIZE(pyargs);
RAII_ARGV(argv, argc); if (!argv) return PyErr_NoMemory();
for (int i = 0; i < argc; i++) {
argv[i] = strdup(PyUnicode_AsUTF8(PyList_GET_ITEM(pyargs, i)));
if (!argv[i]) return PyErr_NoMemory();
}
RAII_ALLOC(FlagSpec, flags, calloc(PyDict_GET_SIZE(names_map), sizeof(FlagSpec))); if (!flags) return PyErr_NoMemory();
RAII_CLISpec(spec);
PyObject *key = NULL, *opt = NULL;
Py_ssize_t pos = 0, flag_num = 0;
while (PyDict_Next(names_map, &pos, &key, &opt)) {
FlagSpec *flag = &flags[flag_num++];
flag->dest = PyUnicode_AsUTF8(key);
PyObject *pytype = PyDict_GetItemString(opt, "type");
const char *type = pytype ? PyUnicode_AsUTF8(pytype) : "";
PyObject *defval = PyDict_GetItemWithError(defval_map, key); if (!defval && PyErr_Occurred()) return NULL;
PyObject *pyaliases = PyDict_GetItemString(opt, "aliases");
for (int a = 0; a < PyTuple_GET_SIZE(pyaliases); a++) {
const char *alias = PyUnicode_AsUTF8(PyTuple_GET_ITEM(pyaliases, a));
if (vt_is_end(vt_insert(&spec.alias_map, alias, flag->dest))) return PyErr_NoMemory();
}
if (strstr(type, "bool-") == type) {
flag->defval.type = CLI_VALUE_BOOL;
flag->defval.boolval = PyObject_IsTrue(defval);
} else if (strcmp(type, "int") == 0) {
flag->defval.type = CLI_VALUE_INT;
flag->defval.intval = PyLong_AsLongLong(defval);
} else if (strcmp(type, "float") == 0) {
flag->defval.type = CLI_VALUE_FLOAT;
flag->defval.floatval = PyFloat_AsDouble(defval);
} else if (strcmp(type, "list") == 0) {
flag->defval.type = CLI_VALUE_LIST;
} else if (strcmp(type, "choices") == 0) {
flag->defval.type = CLI_VALUE_CHOICE;
flag->defval.strval = PyUnicode_AsUTF8(defval);
PyObject *pyc = PyDict_GetItemString(opt, "choices");
flag->defval.listval.items = malloc(PyTuple_GET_SIZE(pyc) * sizeof(char*));
if (!flag->defval.listval.items) return PyErr_NoMemory();
flag->defval.listval.count = PyTuple_GET_SIZE(pyc);
flag->defval.listval.needs_free = true;
flag->defval.listval.capacity = PyTuple_GET_SIZE(pyc);
for (size_t n = 0; n < flag->defval.listval.count; n++) {
flag->defval.listval.items[n] = PyUnicode_AsUTF8(PyTuple_GET_ITEM(pyc, n));
if (!flag->defval.listval.items[n]) return NULL;
}
} else {
flag->defval.type = CLI_VALUE_STRING;
flag->defval.strval = PyUnicode_Check(defval) ? PyUnicode_AsUTF8(defval) : NULL;
}
if (vt_is_end(vt_insert(&spec.flag_map, flag->dest, flag))) return PyErr_NoMemory();
}
if (PyErr_Occurred()) return NULL;
parse_cli_loop(&spec, argc, argv);
PyObject *ans = cli_parse_result_as_python(&spec);
return ans;
}

View file

@ -78,7 +78,6 @@ version
oc.do_print = False
def t(args, leftover=(), fails=False, **expected):
oc.values_map['list'] = []
args = list(shlex_split(args))
ans = CLIOptions()
if fails:
@ -89,13 +88,8 @@ version
self.assertEqual(tuple(leftover), tuple(actual_leftover), f'{args}\n{ans}')
for dest, defval in oc.values_map.items():
val = expected.get(dest, defval)
self.assertEqual(val, getattr(ans, dest), f'Failed to parse {dest} correctly for: {args} \n{ans}')
self.assertEqual(val, getattr(ans, dest, BaseTest), f'Failed to parse {dest} correctly for: {args} \n{ans}')
t('-1 -h', fails=True)
t('-1 --help', fails=True)
t('-1 -0v', fails=True)
t('-1 -v0', fails=True)
t('-1 --version', fails=True)
t('-1', bool_set=True)
t('-01', bool_reset=False, bool_set=True)
t('-01=y', bool_reset=False, bool_set=True)
@ -110,6 +104,11 @@ version
t('--choice moo', fails=True)
t('-1c moo', fails=True)
t('-10c=moo', fails=True)
t('-1 -h', fails=True)
t('-1 --help', fails=True)
t('-1 -0v', fails=True)
t('-1 -v0', fails=True)
t('-1 --version', fails=True)
def conf_parsing(self):