mirror of
https://github.com/ChristianLight/tutor.git
synced 2024-05-28 11:50:49 +00:00
feat: auto-complete config save/printroot
arguments
This commit is contained in:
parent
ee09612326
commit
29eb3398a2
|
@ -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)
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from tutor import serialize
|
from tutor import serialize
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,17 +29,15 @@ class SerializeTests(unittest.TestCase):
|
||||||
def test_parse_empty_string(self) -> None:
|
def test_parse_empty_string(self) -> None:
|
||||||
self.assertEqual("", serialize.parse("''"))
|
self.assertEqual("", serialize.parse("''"))
|
||||||
|
|
||||||
def test_yaml_param_type(self) -> None:
|
def test_parse_key_value(self) -> None:
|
||||||
param = serialize.YamlParamType()
|
self.assertEqual(("name", True), serialize.parse_key_value("name=true"))
|
||||||
self.assertEqual(("name", True), param.convert("name=true", "param", {}))
|
self.assertEqual(("name", "abcd"), serialize.parse_key_value("name=abcd"))
|
||||||
self.assertEqual(("name", "abcd"), param.convert("name=abcd", "param", {}))
|
self.assertEqual(("name", ""), serialize.parse_key_value("name="))
|
||||||
self.assertEqual(("name", ""), param.convert("name=", "param", {}))
|
self.assertIsNone(serialize.parse_key_value("name"))
|
||||||
with self.assertRaises(click.exceptions.BadParameter):
|
self.assertEqual(("x", "a=bcd"), serialize.parse_key_value("x=a=bcd"))
|
||||||
param.convert("name", "param", {})
|
|
||||||
self.assertEqual(("x", "a=bcd"), param.convert("x=a=bcd", "param", {}))
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
("x", {"key1": {"subkey": "value"}, "key2": {"subkey": "value"}}),
|
("x", {"key1": {"subkey": "value"}, "key2": {"subkey": "value"}}),
|
||||||
param.convert(
|
serialize.parse_key_value(
|
||||||
"x=key1:\n subkey: value\nkey2:\n subkey: value", "param", {}
|
"x=key1:\n subkey: value\nkey2:\n subkey: value"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
from typing import List
|
import json
|
||||||
|
import typing as t
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import click.shell_completion
|
||||||
|
|
||||||
from .. import config as tutor_config
|
from .. import config as tutor_config
|
||||||
from .. import env, exceptions, fmt
|
from .. import env, exceptions, fmt
|
||||||
from .. import interactive as interactive_config
|
from .. import interactive as interactive_config
|
||||||
from .. import serialize
|
from .. import serialize
|
||||||
from ..types import Config
|
from ..types import Config, ConfigValue
|
||||||
from .context import Context
|
from .context import Context
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,13 +21,82 @@ def config_command() -> None:
|
||||||
pass
|
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 <KEY>=<YAML VALUE> 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 <KEY>=<VAL> auto-completion.
|
||||||
|
"""
|
||||||
|
if "=" not in incomplete:
|
||||||
|
# Auto-complete with '<KEY>='. 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 '<KEY>=<VALUE>'
|
||||||
|
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.command(help="Create and save configuration interactively")
|
||||||
@click.option("-i", "--interactive", is_flag=True, help="Run interactively")
|
@click.option("-i", "--interactive", is_flag=True, help="Run interactively")
|
||||||
@click.option(
|
@click.option(
|
||||||
"-s",
|
"-s",
|
||||||
"--set",
|
"--set",
|
||||||
"set_vars",
|
"set_vars",
|
||||||
type=serialize.YamlParamType(),
|
type=ConfigKeyValParamType(),
|
||||||
multiple=True,
|
multiple=True,
|
||||||
metavar="KEY=VAL",
|
metavar="KEY=VAL",
|
||||||
help="Set a configuration value (can be used multiple times)",
|
help="Set a configuration value (can be used multiple times)",
|
||||||
|
@ -35,17 +106,18 @@ def config_command() -> None:
|
||||||
"--unset",
|
"--unset",
|
||||||
"unset_vars",
|
"unset_vars",
|
||||||
multiple=True,
|
multiple=True,
|
||||||
|
type=ConfigKeyParamType(),
|
||||||
help="Remove a configuration value (can be used multiple times)",
|
help="Remove a configuration value (can be used multiple times)",
|
||||||
)
|
)
|
||||||
@click.option(
|
@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
|
@click.pass_obj
|
||||||
def save(
|
def save(
|
||||||
context: Context,
|
context: Context,
|
||||||
interactive: bool,
|
interactive: bool,
|
||||||
set_vars: Config,
|
set_vars: Config,
|
||||||
unset_vars: List[str],
|
unset_vars: t.List[str],
|
||||||
env_only: bool,
|
env_only: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
config = tutor_config.load_minimal(context.root)
|
config = tutor_config.load_minimal(context.root)
|
||||||
|
@ -71,7 +143,7 @@ def printroot(context: Context) -> None:
|
||||||
|
|
||||||
|
|
||||||
@click.command(help="Print a configuration value")
|
@click.command(help="Print a configuration value")
|
||||||
@click.argument("key")
|
@click.argument("key", type=ConfigKeyParamType())
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def printvalue(context: Context, key: str) -> None:
|
def printvalue(context: Context, key: str) -> None:
|
||||||
config = tutor_config.load(context.root)
|
config = tutor_config.load(context.root)
|
||||||
|
|
|
@ -1,32 +1,31 @@
|
||||||
import re
|
import re
|
||||||
from typing import IO, Any, Iterator, Tuple, Union
|
import typing as t
|
||||||
|
|
||||||
import click
|
|
||||||
import yaml
|
import yaml
|
||||||
from _io import TextIOWrapper
|
from _io import TextIOWrapper
|
||||||
from yaml.parser import ParserError
|
from yaml.parser import ParserError
|
||||||
from yaml.scanner import ScannerError
|
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)
|
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)
|
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)
|
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)
|
result = yaml.dump(content, default_flow_style=False)
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
return result
|
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.
|
Parse a yaml-formatted string.
|
||||||
"""
|
"""
|
||||||
|
@ -37,17 +36,18 @@ def parse(v: Union[str, IO[str]]) -> Any:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class YamlParamType(click.ParamType):
|
def parse_key_value(text: str) -> t.Optional[t.Tuple[str, t.Any]]:
|
||||||
name = "yaml"
|
"""
|
||||||
PARAM_REGEXP = r"(?P<key>[a-zA-Z0-9_-]+)=(?P<value>(.|\n|\r)*)"
|
Parse <KEY>=<YAML VALUE> command line arguments.
|
||||||
|
|
||||||
def convert(self, value: str, param: Any, ctx: Any) -> Tuple[str, Any]:
|
Return None if text could not be parsed.
|
||||||
match = re.match(self.PARAM_REGEXP, value)
|
"""
|
||||||
if not match:
|
match = re.match(r"(?P<key>[a-zA-Z0-9_-]+)=(?P<value>(.|\n|\r)*)", text)
|
||||||
self.fail("'{}' is not of the form 'key=value'.".format(value), param, ctx)
|
if not match:
|
||||||
key = match.groupdict()["key"]
|
return None
|
||||||
value = match.groupdict()["value"]
|
key = match.groupdict()["key"]
|
||||||
if not value:
|
value = match.groupdict()["value"]
|
||||||
# Empty strings are interpreted as null values, which is incorrect.
|
if not value:
|
||||||
value = "''"
|
# Empty strings are interpreted as null values, which is incorrect.
|
||||||
return key, parse(value)
|
value = "''"
|
||||||
|
return key, parse(value)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user