From 29eb3398a2105b2ea55b26a86e2acf843d7e24f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Thu, 17 Nov 2022 14:14:49 +0100 Subject: [PATCH] feat: auto-complete `config save/printroot` arguments --- changelog.d/20221124_161803_regis_HEAD.md | 2 +- tests/test_serialize.py | 20 +++--- tutor/commands/config.py | 84 +++++++++++++++++++++-- tutor/serialize.py | 40 +++++------ 4 files changed, 107 insertions(+), 39 deletions(-) diff --git a/changelog.d/20221124_161803_regis_HEAD.md b/changelog.d/20221124_161803_regis_HEAD.md index e555bda..06373b7 100644 --- a/changelog.d/20221124_161803_regis_HEAD.md +++ b/changelog.d/20221124_161803_regis_HEAD.md @@ -1 +1 @@ -- [Improvement] Auto-completion of `plugins` arguments: `plugins enable/disable NAME` and `install PATH`. (by @regisb) +- [Improvement] Auto-completion of `plugins` and `config` arguments: `plugins enable/disable NAME`, `plugins install PATH`, `config save --set KEY=VAL`, `config save --unset KEY`, `config printvalue KEY`. (by @regisb) diff --git a/tests/test_serialize.py b/tests/test_serialize.py index 4555751..f0bd9fd 100644 --- a/tests/test_serialize.py +++ b/tests/test_serialize.py @@ -1,7 +1,5 @@ import unittest -import click - from tutor import serialize @@ -31,17 +29,15 @@ class SerializeTests(unittest.TestCase): def test_parse_empty_string(self) -> None: self.assertEqual("", serialize.parse("''")) - def test_yaml_param_type(self) -> None: - param = serialize.YamlParamType() - self.assertEqual(("name", True), param.convert("name=true", "param", {})) - self.assertEqual(("name", "abcd"), param.convert("name=abcd", "param", {})) - self.assertEqual(("name", ""), param.convert("name=", "param", {})) - with self.assertRaises(click.exceptions.BadParameter): - param.convert("name", "param", {}) - self.assertEqual(("x", "a=bcd"), param.convert("x=a=bcd", "param", {})) + def test_parse_key_value(self) -> None: + self.assertEqual(("name", True), serialize.parse_key_value("name=true")) + self.assertEqual(("name", "abcd"), serialize.parse_key_value("name=abcd")) + self.assertEqual(("name", ""), serialize.parse_key_value("name=")) + self.assertIsNone(serialize.parse_key_value("name")) + self.assertEqual(("x", "a=bcd"), serialize.parse_key_value("x=a=bcd")) self.assertEqual( ("x", {"key1": {"subkey": "value"}, "key2": {"subkey": "value"}}), - param.convert( - "x=key1:\n subkey: value\nkey2:\n subkey: value", "param", {} + serialize.parse_key_value( + "x=key1:\n subkey: value\nkey2:\n subkey: value" ), ) diff --git a/tutor/commands/config.py b/tutor/commands/config.py index 83d347a..f48bf85 100644 --- a/tutor/commands/config.py +++ b/tutor/commands/config.py @@ -1,12 +1,14 @@ -from typing import List +import json +import typing as t import click +import click.shell_completion from .. import config as tutor_config from .. import env, exceptions, fmt from .. import interactive as interactive_config from .. import serialize -from ..types import Config +from ..types import Config, ConfigValue from .context import Context @@ -19,13 +21,82 @@ def config_command() -> None: pass +class ConfigKeyParamType(click.ParamType): + + name = "configkey" + + def shell_complete( + self, ctx: click.Context, param: click.Parameter, incomplete: str + ) -> t.List[click.shell_completion.CompletionItem]: + return [ + click.shell_completion.CompletionItem(key) + for key, _value in self._shell_complete_config_items(ctx, incomplete) + ] + + @staticmethod + def _shell_complete_config_items( + ctx: click.Context, incomplete: str + ) -> t.List[t.Tuple[str, ConfigValue]]: + # Here we want to auto-complete the name of the config key. For that we need to + # figure out the list of enabled plugins, and for that we need the project root. + # The project root would ordinarily be stored in ctx.obj.root, but during + # auto-completion we don't have access to our custom Tutor context. So we resort + # to a dirty hack, which is to examine the grandparent context. + root = getattr( + getattr(getattr(ctx, "parent", None), "parent", None), "params", {} + ).get("root", "") + config = tutor_config.load_full(root) + return [ + (key, value) for key, value in config.items() if key.startswith(incomplete) + ] + + +class ConfigKeyValParamType(ConfigKeyParamType): + """ + Parser for = command line arguments. + """ + + name = "configkeyval" + + def convert(self, value: str, param: t.Any, ctx: t.Any) -> t.Tuple[str, t.Any]: + result = serialize.parse_key_value(value) + if result is None: + self.fail(f"'{value}' is not of the form 'key=value'.", param, ctx) + return result + + def shell_complete( + self, ctx: click.Context, param: click.Parameter, incomplete: str + ) -> t.List[click.shell_completion.CompletionItem]: + """ + Nice and friendly = auto-completion. + """ + if "=" not in incomplete: + # Auto-complete with '='. Note the single quotes which allow users to + # further auto-complete later. + return [ + click.shell_completion.CompletionItem(f"'{key}='") + for key, value in self._shell_complete_config_items(ctx, incomplete) + ] + if incomplete.endswith("="): + # raise ValueError(f"incomplete: <{incomplete}>") + # Auto-complete with '=' + return [ + click.shell_completion.CompletionItem(f"{key}={json.dumps(value)}") + for key, value in self._shell_complete_config_items( + ctx, incomplete[:-1] + ) + ] + # Else, don't bother + return [] + + @click.command(help="Create and save configuration interactively") @click.option("-i", "--interactive", is_flag=True, help="Run interactively") @click.option( "-s", "--set", "set_vars", - type=serialize.YamlParamType(), + type=ConfigKeyValParamType(), multiple=True, metavar="KEY=VAL", help="Set a configuration value (can be used multiple times)", @@ -35,17 +106,18 @@ def config_command() -> None: "--unset", "unset_vars", multiple=True, + type=ConfigKeyParamType(), help="Remove a configuration value (can be used multiple times)", ) @click.option( - "-e", "--env-only", "env_only", is_flag=True, help="Skip updating config.yaml" + "-e", "--env-only", "env_only", is_flag=True, help="Skip updating config.yml" ) @click.pass_obj def save( context: Context, interactive: bool, set_vars: Config, - unset_vars: List[str], + unset_vars: t.List[str], env_only: bool, ) -> None: config = tutor_config.load_minimal(context.root) @@ -71,7 +143,7 @@ def printroot(context: Context) -> None: @click.command(help="Print a configuration value") -@click.argument("key") +@click.argument("key", type=ConfigKeyParamType()) @click.pass_obj def printvalue(context: Context, key: str) -> None: config = tutor_config.load(context.root) diff --git a/tutor/serialize.py b/tutor/serialize.py index e835c23..415f75c 100644 --- a/tutor/serialize.py +++ b/tutor/serialize.py @@ -1,32 +1,31 @@ import re -from typing import IO, Any, Iterator, Tuple, Union +import typing as t -import click import yaml from _io import TextIOWrapper from yaml.parser import ParserError from yaml.scanner import ScannerError -def load(stream: Union[str, IO[str]]) -> Any: +def load(stream: t.Union[str, t.IO[str]]) -> t.Any: return yaml.load(stream, Loader=yaml.SafeLoader) -def load_all(stream: str) -> Iterator[Any]: +def load_all(stream: str) -> t.Iterator[t.Any]: return yaml.load_all(stream, Loader=yaml.SafeLoader) -def dump(content: Any, fileobj: TextIOWrapper) -> None: +def dump(content: t.Any, fileobj: TextIOWrapper) -> None: yaml.dump(content, stream=fileobj, default_flow_style=False) -def dumps(content: Any) -> str: +def dumps(content: t.Any) -> str: result = yaml.dump(content, default_flow_style=False) assert isinstance(result, str) return result -def parse(v: Union[str, IO[str]]) -> Any: +def parse(v: t.Union[str, t.IO[str]]) -> t.Any: """ Parse a yaml-formatted string. """ @@ -37,17 +36,18 @@ def parse(v: Union[str, IO[str]]) -> Any: return v -class YamlParamType(click.ParamType): - name = "yaml" - PARAM_REGEXP = r"(?P[a-zA-Z0-9_-]+)=(?P(.|\n|\r)*)" +def parse_key_value(text: str) -> t.Optional[t.Tuple[str, t.Any]]: + """ + Parse = command line arguments. - def convert(self, value: str, param: Any, ctx: Any) -> Tuple[str, Any]: - match = re.match(self.PARAM_REGEXP, value) - if not match: - self.fail("'{}' is not of the form 'key=value'.".format(value), param, ctx) - key = match.groupdict()["key"] - value = match.groupdict()["value"] - if not value: - # Empty strings are interpreted as null values, which is incorrect. - value = "''" - return key, parse(value) + Return None if text could not be parsed. + """ + match = re.match(r"(?P[a-zA-Z0-9_-]+)=(?P(.|\n|\r)*)", text) + if not match: + return None + key = match.groupdict()["key"] + value = match.groupdict()["value"] + if not value: + # Empty strings are interpreted as null values, which is incorrect. + value = "''" + return key, parse(value)