add updates libs
This commit is contained in:
@@ -1,37 +1,4 @@
|
||||
import pathlib
|
||||
from typing import List, Dict, Union
|
||||
|
||||
from tclint.commands import builtin as _builtin
|
||||
from tclint.commands.plugins import PluginManager
|
||||
|
||||
# import to expose in package
|
||||
# Import to expose in package.
|
||||
from tclint.commands.checks import CommandArgError
|
||||
|
||||
__all__ = ["CommandArgError", "validate_command_plugins", "get_commands"]
|
||||
|
||||
|
||||
def validate_command_plugins(plugins: List[str]) -> List[str]:
|
||||
valid_plugins = []
|
||||
for plugin in set(plugins):
|
||||
if PluginManager.load(plugin) is not None:
|
||||
valid_plugins.append(plugin)
|
||||
|
||||
return valid_plugins
|
||||
|
||||
|
||||
def get_commands(plugins: List[Union[str, pathlib.Path]]) -> Dict:
|
||||
commands = {}
|
||||
commands.update(_builtin.commands)
|
||||
|
||||
for plugin in plugins:
|
||||
if isinstance(plugin, str):
|
||||
plugin_commands = PluginManager.load(plugin)
|
||||
elif isinstance(plugin, pathlib.Path):
|
||||
plugin_commands = PluginManager.load_from_spec(plugin)
|
||||
else:
|
||||
raise TypeError(f"Plugins must be strings or paths, got {type(plugin)}")
|
||||
|
||||
if plugin_commands is not None:
|
||||
commands.update(plugin_commands)
|
||||
|
||||
return commands
|
||||
__all__ = ["CommandArgError"]
|
||||
|
||||
@@ -32,13 +32,9 @@ these would be helpful for your use case, please file an issue.
|
||||
- https://www.tcl.tk/man/tcl/TclCmd/mathop.html
|
||||
"""
|
||||
|
||||
from tclint.commands.checks import (
|
||||
CommandArgError,
|
||||
check_count,
|
||||
eval,
|
||||
)
|
||||
from tclint.commands.checks import CommandArgError, check_arg_spec, check_count, eval
|
||||
from tclint.commands.schema import commands_schema
|
||||
from tclint.syntax_tree import BareWord
|
||||
from tclint.syntax_tree import BareWord, Node
|
||||
|
||||
|
||||
def _check_code(arg):
|
||||
@@ -77,7 +73,7 @@ def _after(args, parser):
|
||||
def _after_cancel(args, parser):
|
||||
"""after id|(script...)"""
|
||||
# ref: https://www.tcl.tk/man/tcl/TclCmd/after.html
|
||||
check_count("after cancel", 1, None)
|
||||
check_count("after cancel", 1, None)(args, parser)
|
||||
|
||||
# TODO: raise warning about not checking code
|
||||
|
||||
@@ -181,20 +177,6 @@ _array = {
|
||||
}
|
||||
|
||||
|
||||
def _catch(args, parser):
|
||||
"""catch script [resultVarName] [optionsVarName]"""
|
||||
if len(args) < 1:
|
||||
raise CommandArgError(
|
||||
f"not enough args to catch: got {len(args)}, expected at least 1"
|
||||
)
|
||||
if len(args) > 3:
|
||||
raise CommandArgError(
|
||||
f"too many args to catch: got {len(args)}, expected no more than 3"
|
||||
)
|
||||
|
||||
return [parser.parse_script(args[0])] + args[1:]
|
||||
|
||||
|
||||
_chan = {
|
||||
"subcommands": {
|
||||
"blocked": {
|
||||
@@ -350,43 +332,37 @@ def _dict_filter(args, parser):
|
||||
|
||||
def _dict_map_for(cmd):
|
||||
def check(args, parser):
|
||||
if len(args) != 3:
|
||||
raise CommandArgError(
|
||||
f"wrong # of args to '{cmd}': got {len(args)}, expected 3"
|
||||
)
|
||||
|
||||
spec = {
|
||||
"positionals": [
|
||||
{"name": "keyValueList", "value": {"type": "any"}, "required": True},
|
||||
{"name": "dictionaryValue", "value": {"type": "any"}, "required": True},
|
||||
{"name": "body", "value": {"type": "script"}, "required": True},
|
||||
],
|
||||
"switches": {},
|
||||
}
|
||||
# TODO: might be worth checking that arg[0] is a pair?
|
||||
|
||||
return args[0:2] + [parser.parse_script(args[2])]
|
||||
return check_arg_spec(cmd, args, parser, spec)
|
||||
|
||||
return check
|
||||
|
||||
|
||||
def _dict_update(args, parser):
|
||||
# ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M25
|
||||
"""dict update dictionaryVariable key varName ?key varName ...? body
|
||||
|
||||
if len(args) < 4:
|
||||
raise CommandArgError(
|
||||
f"not enough args to 'dict update': got {len(args)}, expected at least 4"
|
||||
)
|
||||
|
||||
if len(args) % 2 != 0:
|
||||
raise CommandArgError(
|
||||
"invalid # of args to 'dict update': expected an even number"
|
||||
)
|
||||
|
||||
return args[0:-1] + [parser.parse_script(args[-1])]
|
||||
|
||||
|
||||
def _dict_with(args, parser):
|
||||
# ref: https://www.tcl.tk/man/tcl/TclCmd/dict.html#M27
|
||||
|
||||
if len(args) < 2:
|
||||
raise CommandArgError(
|
||||
f"not enough args to 'dict with': got {len(args)}, expected at least 2"
|
||||
)
|
||||
|
||||
return args[0:-1] + [parser.parse_script(args[-1])]
|
||||
ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/dict.htm#M25
|
||||
"""
|
||||
spec = {
|
||||
"positionals": [
|
||||
{"name": "dictionaryVariable", "value": {"type": "any"}, "required": True},
|
||||
{"name": "key", "value": {"type": "any"}, "required": True},
|
||||
{"name": "varName", "value": {"type": "any"}, "required": True},
|
||||
{"name": "key varName", "value": {"type": "variadic"}, "required": False},
|
||||
{"name": "body", "value": {"type": "script"}, "required": True},
|
||||
],
|
||||
"switches": {},
|
||||
}
|
||||
# TODO: Check that number of variadic words is even.
|
||||
return check_arg_spec("dict update", args, parser, spec)
|
||||
|
||||
|
||||
def _eval(args, parser):
|
||||
@@ -434,53 +410,100 @@ def _fileevent(args, parser):
|
||||
)
|
||||
|
||||
|
||||
def _for(args, parser):
|
||||
# ref: https://www.tcl.tk/man/tcl/TclCmd/for.html
|
||||
if len(args) != 4:
|
||||
raise CommandArgError(f"wrong # of args to for: got {len(args)}, expected 4")
|
||||
def foreach(args, parser):
|
||||
"""
|
||||
foreach varname list ?varlist list ...? body
|
||||
|
||||
return [
|
||||
parser.parse_script(args[0]),
|
||||
parser.parse_expression(args[1]),
|
||||
parser.parse_script(args[2]),
|
||||
parser.parse_script(args[3]),
|
||||
]
|
||||
ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/foreach.htm
|
||||
"""
|
||||
spec = {
|
||||
"positionals": [
|
||||
{"name": "varname", "value": {"type": "any"}, "required": True},
|
||||
{"name": "list", "value": {"type": "any"}, "required": True},
|
||||
{"name": "varlist list", "value": {"type": "variadic"}, "required": False},
|
||||
{"name": "body", "value": {"type": "script"}, "required": True},
|
||||
],
|
||||
"switches": {},
|
||||
}
|
||||
# TODO: check that "varlist list" comes in pairs.
|
||||
return check_arg_spec("foreach", args, parser, spec)
|
||||
|
||||
|
||||
def _foreach(args, parser):
|
||||
# ref: https://www.tcl.tk/man/tcl/TclCmd/foreach.html
|
||||
if len(args) < 3:
|
||||
raise CommandArgError(
|
||||
f"insufficient args to foreach: got {len(args)}, expected at least 3"
|
||||
)
|
||||
def _if(args, parser) -> list[Node]:
|
||||
# ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/if.htm
|
||||
|
||||
# last argument is script body
|
||||
return args[0:-1] + [parser.parse_script(args[-1])]
|
||||
|
||||
|
||||
def _if(args, parser):
|
||||
# ref: https://www.tcl.tk/man/tcl/TclCmd/if.html
|
||||
# TODO: make arg checking strict
|
||||
|
||||
new_args = []
|
||||
new_args: list[Node] = []
|
||||
|
||||
# Parse if condition.
|
||||
if len(new_args) == len(args):
|
||||
raise CommandArgError("Expected condition argument in 'if'")
|
||||
new_args.append(parser.parse_expression(args[0]))
|
||||
|
||||
while len(new_args) < len(args):
|
||||
arg = args[len(new_args)]
|
||||
|
||||
if arg.contents == "then" or arg.contents == "else":
|
||||
new_args.append(arg)
|
||||
continue
|
||||
if arg.contents == "elseif":
|
||||
new_args.append(arg)
|
||||
new_args.append(parser.parse_expression(args[len(new_args)]))
|
||||
continue
|
||||
|
||||
arg = parser.parse_script(arg)
|
||||
# Parse optional noise word then.
|
||||
if len(new_args) == len(args):
|
||||
raise CommandArgError("Expected then or body argument in 'if'")
|
||||
arg = args[len(new_args)]
|
||||
if arg.contents == "then":
|
||||
new_args.append(arg)
|
||||
|
||||
return new_args
|
||||
# Parse if body.
|
||||
if len(new_args) == len(args):
|
||||
raise CommandArgError("Expected body argument in 'if'")
|
||||
new_args.append(parser.parse_script(args[len(new_args)]))
|
||||
|
||||
# Parse elseif.
|
||||
while (
|
||||
len(new_args) < len(args)
|
||||
and (arg := args[len(new_args)])
|
||||
and arg.contents == "elseif"
|
||||
):
|
||||
new_args.append(arg)
|
||||
|
||||
# Parse elseif condition.
|
||||
if len(new_args) == len(args):
|
||||
raise CommandArgError(
|
||||
"Expected condition argument in 'elseif' part of 'if'"
|
||||
)
|
||||
new_args.append(parser.parse_expression(args[len(new_args)]))
|
||||
|
||||
# Parse optional noise word then.
|
||||
if len(new_args) == len(args):
|
||||
raise CommandArgError(
|
||||
"Expected then or body argument in 'elseif' part of 'if'"
|
||||
)
|
||||
arg = args[len(new_args)]
|
||||
if arg.contents == "then":
|
||||
new_args.append(arg)
|
||||
|
||||
# Parse elseif body.
|
||||
if len(new_args) == len(args):
|
||||
raise CommandArgError("Expected body argument in 'elseif' part of 'if'")
|
||||
new_args.append(parser.parse_script(args[len(new_args)]))
|
||||
|
||||
if len(new_args) == len(args):
|
||||
# No else part, we're done.
|
||||
return new_args
|
||||
|
||||
# Parse optional noise word else.
|
||||
arg = args[len(new_args)]
|
||||
if arg.contents == "else":
|
||||
new_args.append(arg)
|
||||
|
||||
# Parse else body.
|
||||
if len(new_args) == len(args):
|
||||
raise CommandArgError("Expected body argument to 'else' part of 'if'")
|
||||
new_args.append(parser.parse_script(args[len(new_args)]))
|
||||
|
||||
if len(new_args) == len(args):
|
||||
# Else part parsed, we're done.
|
||||
return new_args
|
||||
|
||||
# Handle superfluous args.
|
||||
arg = args[len(new_args)]
|
||||
if arg.contents is None or not arg.contents:
|
||||
raise CommandArgError("Argument after complete 'if'")
|
||||
else:
|
||||
raise CommandArgError(f"Argument after complete 'if': {arg.contents}")
|
||||
|
||||
|
||||
def _interp_eval(args, parser):
|
||||
@@ -492,13 +515,22 @@ def _interp_eval(args, parser):
|
||||
|
||||
|
||||
def _lmap(args, parser):
|
||||
# ref: https://www.tcl.tk/man/tcl/TclCmd/lmap.html
|
||||
if len(args) < 3:
|
||||
raise CommandArgError(
|
||||
f"not enough args to lmap: got {len(args)}, expected at least 3"
|
||||
)
|
||||
"""
|
||||
lmap varlist1 list1 ?varlist2 list2 ...? body
|
||||
|
||||
return args[:-1] + [parser.parse_script(args[-1])]
|
||||
ref: https://www.tcl-lang.org/man/tcl8.6/TclCmd/lmap.htm
|
||||
"""
|
||||
spec = {
|
||||
"positionals": [
|
||||
{"name": "varlist1", "value": {"type": "any"}, "required": True},
|
||||
{"name": "list1", "value": {"type": "any"}, "required": True},
|
||||
{"name": "varlist list", "value": {"type": "variadic"}, "required": False},
|
||||
{"name": "body", "value": {"type": "script"}, "required": True},
|
||||
],
|
||||
"switches": {},
|
||||
}
|
||||
# TODO: Check that number of variadic words is even.
|
||||
return check_arg_spec("lmap", args, parser, spec)
|
||||
|
||||
|
||||
def _namespace_code(args, parser):
|
||||
@@ -656,6 +688,9 @@ def _switch(args, parser):
|
||||
for i, node in enumerate(pattern_and_commands):
|
||||
if i % 2 == 0:
|
||||
parsed_patterns_and_commands.append(node)
|
||||
elif node.contents == "-":
|
||||
# Detect passthrough.
|
||||
parsed_patterns_and_commands.append(node)
|
||||
else:
|
||||
parsed_patterns_and_commands.append(parser.parse_script(node))
|
||||
|
||||
@@ -795,16 +830,6 @@ def _try(args, parser):
|
||||
return new_args
|
||||
|
||||
|
||||
def _while(args, parser):
|
||||
if len(args) != 2:
|
||||
raise CommandArgError(f"wrong # of args to while: got {len(args)}, expected 2")
|
||||
|
||||
return [
|
||||
parser.parse_expression(args[0]),
|
||||
parser.parse_script(args[1]),
|
||||
]
|
||||
|
||||
|
||||
commands = commands_schema({
|
||||
"after": {
|
||||
"subcommands": {
|
||||
@@ -828,14 +853,63 @@ commands = commands_schema({
|
||||
"array": _array,
|
||||
"binary": {
|
||||
"subcommands": {
|
||||
"decode": check_count("binary decode", 2, None),
|
||||
"encode": check_count("binary encode", 2, None),
|
||||
"format": check_count("binary format", 1, None),
|
||||
"scan": check_count("binary scan", 2, None),
|
||||
"decode": {
|
||||
"positionals": [
|
||||
{"name": "format", "value": {"type": "any"}, "required": True},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {"type": "variadic"},
|
||||
"required": False,
|
||||
},
|
||||
{"name": "data", "value": {"type": "any"}, "required": True},
|
||||
],
|
||||
},
|
||||
"encode": {
|
||||
"positionals": [
|
||||
{"name": "format", "value": {"type": "any"}, "required": True},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {"type": "variadic"},
|
||||
"required": False,
|
||||
},
|
||||
{"name": "data", "value": {"type": "any"}, "required": True},
|
||||
],
|
||||
},
|
||||
"format": {
|
||||
"positionals": [
|
||||
{
|
||||
"name": "formatString",
|
||||
"value": {"type": "any"},
|
||||
"required": True,
|
||||
},
|
||||
{"name": "args", "value": {"type": "variadic"}, "required": False},
|
||||
],
|
||||
},
|
||||
"scan": {
|
||||
"positionals": [
|
||||
{"name": "string", "value": {"type": "any"}, "required": True},
|
||||
{
|
||||
"name": "formatString",
|
||||
"value": {"type": "any"},
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "varName",
|
||||
"value": {"type": "variadic"},
|
||||
"required": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"break": check_count("break", 0, 0),
|
||||
"catch": _catch,
|
||||
"break": {},
|
||||
"catch": {
|
||||
"positionals": [
|
||||
{"name": "script", "value": {"type": "script"}, "required": True},
|
||||
{"name": "resultVarName", "value": {"type": "any"}, "required": False},
|
||||
{"name": "optionsVarName", "value": {"type": "any"}, "required": False},
|
||||
]
|
||||
},
|
||||
"cd": {
|
||||
"positionals": [
|
||||
{"name": "dirName", "value": {"type": "any"}, "required": False}
|
||||
@@ -884,7 +958,17 @@ commands = commands_schema({
|
||||
"unset": check_count("dict unset", 2, None),
|
||||
"update": _dict_update,
|
||||
"values": check_count("dict values", 1, 2),
|
||||
"with": _dict_with,
|
||||
"with": {
|
||||
"positionals": [
|
||||
{
|
||||
"name": "dictionaryVariable",
|
||||
"value": {"type": "any"},
|
||||
"required": True,
|
||||
},
|
||||
{"name": "key", "value": {"type": "variadic"}, "required": False},
|
||||
{"name": "script", "value": {"type": "script"}, "required": True},
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
"encoding": {
|
||||
@@ -909,8 +993,15 @@ commands = commands_schema({
|
||||
"file": check_count("file", 1, None),
|
||||
"fileevent": _fileevent,
|
||||
"flush": check_count("flush", 1, 1),
|
||||
"for": _for,
|
||||
"foreach": _foreach,
|
||||
"for": {
|
||||
"positionals": [
|
||||
{"name": "start", "value": {"type": "script"}, "required": True},
|
||||
{"name": "test", "value": {"type": "expression"}, "required": True},
|
||||
{"name": "next", "value": {"type": "script"}, "required": True},
|
||||
{"name": "body", "value": {"type": "script"}, "required": True},
|
||||
],
|
||||
},
|
||||
"foreach": foreach,
|
||||
"format": check_count("format", 1, None),
|
||||
"gets": check_count("gets", 1, 2),
|
||||
"glob": check_count("glob"),
|
||||
@@ -974,8 +1065,28 @@ commands = commands_schema({
|
||||
"inscope": _namespace_inscope,
|
||||
"origin": check_count("namespace origin", 1, 1),
|
||||
"parent": check_count("namespace parent", 0, 1),
|
||||
"path": {
|
||||
"positionals": [
|
||||
{
|
||||
"name": "namespaceList",
|
||||
"value": {"type": "any"},
|
||||
"required": False,
|
||||
},
|
||||
]
|
||||
},
|
||||
"qualifiers": check_count("namespace qualifiers", 1, 1),
|
||||
"tail": check_count("namespace tail", 1, 1),
|
||||
"unknown": {
|
||||
"positionals": [
|
||||
{"name": "script", "value": {"type": "script"}, "required": False}
|
||||
]
|
||||
},
|
||||
"upvar": {
|
||||
"positionals": [
|
||||
{"name": "namespace", "value": {"type": "any"}, "required": True},
|
||||
{"name": "var", "value": {"type": "variadic"}, "required": False},
|
||||
]
|
||||
},
|
||||
"which": check_count("namespace which", 1, 2),
|
||||
"ensemble": {
|
||||
"subcommands": {
|
||||
@@ -1028,7 +1139,7 @@ commands = commands_schema({
|
||||
"source": check_count("source", 1, 3),
|
||||
"split": check_count("split", 1, 2),
|
||||
# TODO: check subcommands
|
||||
"string": check_count("string", 2, None),
|
||||
"string": check_count("string", 1, None),
|
||||
"subst": check_count("subst", 1, 4),
|
||||
"switch": _switch,
|
||||
"tailcall": check_count("tailcall", 1, None),
|
||||
@@ -1061,7 +1172,12 @@ commands = commands_schema({
|
||||
"upvar": check_count("upvar", 2, None),
|
||||
"variable": check_count("variable", 1, None),
|
||||
"vwait": check_count("vwait", 1, 1),
|
||||
"while": _while,
|
||||
"while": {
|
||||
"positionals": [
|
||||
{"name": "test", "value": {"type": "expression"}, "required": True},
|
||||
{"name": "body", "value": {"type": "script"}, "required": True},
|
||||
],
|
||||
},
|
||||
"yield": {
|
||||
"positionals": [
|
||||
{"name": "value", "value": {"type": "any"}, "required": False},
|
||||
@@ -1074,5 +1190,5 @@ commands = commands_schema({
|
||||
]
|
||||
},
|
||||
# TODO: check subcommands
|
||||
"zlib": check_count("zlib", 3, None),
|
||||
"zlib": check_count("zlib", 2, None),
|
||||
})
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
"""Helpers for checking command arguments."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import List, Optional, Union
|
||||
from __future__ import annotations
|
||||
|
||||
from tclint.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from tclint.syntax_tree import ArgExpansion, BareWord, BracedWord, Node, QuotedWord
|
||||
|
||||
# This lets us use Parser in type annotations without introducing a cyclic dependency.
|
||||
if TYPE_CHECKING:
|
||||
from tclint.parser import Parser
|
||||
|
||||
|
||||
class CommandArgError(Exception):
|
||||
"""Exception raised by command handlers to indicate invalid arguments."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def arg_count(args, parser):
|
||||
# TODO: graceful handling of argsub going into things with recursive parsing.
|
||||
# if the argsub happens to be "concrete", we can technically do the right
|
||||
# thing (although this should probably be flagged as a readability issue...)
|
||||
# otherwise, we should flag that the non-concrete argsub is not okay for
|
||||
# these cases. however, I think its not okay-ness doesn't need to be absolute, e.g.
|
||||
# I think we could allow:
|
||||
#
|
||||
# catch {puts "my script"} {*}$catchopts
|
||||
#
|
||||
def arg_count(args: list[Node], parser: Parser) -> tuple[int, bool]:
|
||||
"""Returns the number of arguments in args, taking {*} into account.
|
||||
|
||||
If an argument list contains an argument expansion operator that cannot be
|
||||
statically expanded, the second return value is True, and the count is the minimum
|
||||
possible number of arguments. Otherwise, the return value is False and the count
|
||||
reflects the exact number of arguments.
|
||||
|
||||
This function should always be used for validating argument count, rather than
|
||||
relying on `len(args)`.
|
||||
"""
|
||||
|
||||
# TODO: Replace this with or add a similar `expand_args` function that returns an
|
||||
# expanded argument list. One tricky thing is we want to handle cases like:
|
||||
# - foreach {*}$iters { ...body... }
|
||||
# - catch {puts "my script"} {*}$catchopts
|
||||
# With something like a list structure we can either forward or reverse index to
|
||||
# still parse the body even with an unexpanded {*}.
|
||||
|
||||
# TODO: Add a violation that flags `{*}{a b c}` for rewriting as `a b c`. A future
|
||||
# version of tclfmt that allows rewrites that break the syntax tree could do this
|
||||
# automatically.
|
||||
|
||||
arg_count = 0
|
||||
has_arg_expansion = False
|
||||
@@ -28,14 +48,14 @@ def arg_count(args, parser):
|
||||
if arg.contents is None:
|
||||
has_arg_expansion = True
|
||||
continue
|
||||
arg_count += len(parser.parse_list(arg.contents))
|
||||
arg_count += len(parser.parse_list(arg).children)
|
||||
else:
|
||||
arg_count += 1
|
||||
|
||||
return arg_count, has_arg_expansion
|
||||
|
||||
|
||||
def check_count(command, min=None, max=None, args_name="args"):
|
||||
def check_count(command, min=None, max=None):
|
||||
def check(args, parser):
|
||||
if min is None and max is None:
|
||||
return None
|
||||
@@ -44,19 +64,17 @@ def check_count(command, min=None, max=None, args_name="args"):
|
||||
|
||||
if not has_arg_expansion and min == max and count != min:
|
||||
raise CommandArgError(
|
||||
f"wrong # of {args_name} for {command}: got {count}, expected {min}"
|
||||
f"wrong # of args for {command}: got {count}, expected {min}"
|
||||
)
|
||||
|
||||
if not has_arg_expansion and min is not None and count < min:
|
||||
raise CommandArgError(
|
||||
f"not enough {args_name} for {command}: got {count}, expected at least"
|
||||
f" {min}"
|
||||
f"not enough args for {command}: got {count}, expected at least {min}"
|
||||
)
|
||||
|
||||
if max is not None and count > max:
|
||||
raise CommandArgError(
|
||||
f"too many {args_name} for {command}: got {count}, expected no more"
|
||||
f" than {max}"
|
||||
f"too many args for {command}: got {count}, expected no more than {max}"
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -64,7 +82,7 @@ def check_count(command, min=None, max=None, args_name="args"):
|
||||
return check
|
||||
|
||||
|
||||
def eval(args, parser, command):
|
||||
def eval(args: list[Node], parser: Parser, command: str) -> list[Node]:
|
||||
if len(args) > 1 and any(isinstance(arg, (QuotedWord, BracedWord)) for arg in args):
|
||||
# Slightly odd restriction, but our syntax tree doesn't have a great way
|
||||
# to handle this case. We require each command argument to correspond to
|
||||
@@ -106,15 +124,18 @@ def eval(args, parser, command):
|
||||
|
||||
prev_arg_end_pos = arg.end_pos
|
||||
|
||||
script = parser.parse(eval_script, pos=(args[0].pos))
|
||||
script = parser.parse(eval_script, pos=(args[0].contents_pos))
|
||||
script.end_pos = args[-1].end_pos
|
||||
|
||||
return [script]
|
||||
|
||||
|
||||
def check_command(
|
||||
command: str, args: List[Node], parser, command_spec: Union[Callable, dict, None]
|
||||
) -> Optional[List[Node]]:
|
||||
command: str,
|
||||
args: list[Node],
|
||||
parser: Parser,
|
||||
command_spec: Callable | dict | None,
|
||||
) -> Optional[list[Node]]:
|
||||
if command_spec is None:
|
||||
return None
|
||||
|
||||
@@ -124,42 +145,97 @@ def check_command(
|
||||
return command_spec(args, parser)
|
||||
|
||||
|
||||
def _positional_has_type(type: str, arg_spec: dict, indices: list[int]) -> bool:
|
||||
return any([arg_spec["positionals"][i]["value"]["type"] == type for i in indices])
|
||||
|
||||
|
||||
def check_arg_spec(
|
||||
command: str, args: List[Node], parser, arg_spec: dict
|
||||
) -> Optional[List[Node]]:
|
||||
command: str, args: list[Node], parser: Parser, arg_spec: dict
|
||||
) -> Optional[list[Node]]:
|
||||
if "subcommands" in arg_spec:
|
||||
subcommands = arg_spec["subcommands"]
|
||||
try:
|
||||
subcommand = args[0].contents
|
||||
except IndexError:
|
||||
subcommand = None
|
||||
|
||||
if subcommand in subcommands:
|
||||
new_args = check_command(
|
||||
f"{command} {subcommand}", args[1:], parser, subcommands[subcommand]
|
||||
)
|
||||
if new_args is None:
|
||||
return new_args
|
||||
return args[0:1] + new_args
|
||||
|
||||
if "" in subcommands:
|
||||
return check_command(command, args, parser, subcommands[""])
|
||||
|
||||
if subcommand is not None:
|
||||
msg = f"invalid subcommand for {command}: got {subcommand}"
|
||||
else:
|
||||
msg = f"no subcommand provided for {command}"
|
||||
|
||||
raise CommandArgError(f"{msg}, expected one of {', '.join(subcommands.keys())}")
|
||||
return dispatch_subcommands(command, args, parser, arg_spec["subcommands"])
|
||||
|
||||
switches = arg_spec["switches"]
|
||||
args_allowed = set(switches)
|
||||
mapped, positional_args = map_switches(args, switches, command)
|
||||
|
||||
args_required = {switch for switch in switches if switches[switch]["required"]}
|
||||
missing_required = args_required.difference(mapped)
|
||||
|
||||
if len(missing_required) > 1:
|
||||
raise CommandArgError(
|
||||
f"missing required arguments for {command}: {', '.join(missing_required)}"
|
||||
)
|
||||
elif len(missing_required) == 1:
|
||||
raise CommandArgError(
|
||||
f"missing required argument for {command}: {missing_required.pop()}"
|
||||
)
|
||||
|
||||
positionals = [args[i] for i in positional_args]
|
||||
mapping = map_positionals(positionals, arg_spec["positionals"], command)
|
||||
args = list(args)
|
||||
for arg_i, map_to_spec in zip(positional_args, mapping):
|
||||
if _positional_has_type("script", arg_spec, map_to_spec):
|
||||
args[arg_i] = parser.parse_script(args[arg_i])
|
||||
elif _positional_has_type("expression", arg_spec, map_to_spec):
|
||||
args[arg_i] = parser.parse_expression(args[arg_i])
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def dispatch_subcommands(
|
||||
command: str, args: list[Node], parser: Parser, spec: dict
|
||||
) -> Optional[list[Node]]:
|
||||
try:
|
||||
subcommand = args[0].contents
|
||||
except IndexError:
|
||||
subcommand = None
|
||||
|
||||
if subcommand in spec:
|
||||
new_args = check_command(
|
||||
f"{command} {subcommand}", args[1:], parser, spec[subcommand]
|
||||
)
|
||||
if new_args is None:
|
||||
return new_args
|
||||
return args[0:1] + new_args
|
||||
|
||||
if "" in spec:
|
||||
return check_command(command, args, parser, spec[""])
|
||||
|
||||
if subcommand is not None:
|
||||
msg = f"invalid subcommand for {command}: got {subcommand}"
|
||||
else:
|
||||
msg = f"no subcommand provided for {command}"
|
||||
|
||||
raise CommandArgError(f"{msg}, expected one of {', '.join(spec.keys())}")
|
||||
|
||||
|
||||
def map_switches(
|
||||
args: list[Node], switches: dict, command_name: str
|
||||
) -> tuple[set[str], list[int]]:
|
||||
"""Separates switch arguments from positional arguments in a command's argument
|
||||
list.
|
||||
|
||||
`switches` represents the "switches" entry of the spec for the given command. The
|
||||
return value is a tuple of (mapped_switches, positional_indices). mapped_switches is
|
||||
a set of switch names that were found in args. positional_indices is a list of
|
||||
indices into args for arguments that are not switches.
|
||||
|
||||
If the switches found do not map correctly to the spec, this function raises
|
||||
CommandArgError.
|
||||
|
||||
The `command_name` argument is used to generate descriptive error messages.
|
||||
"""
|
||||
mapped: set[str] = set()
|
||||
|
||||
if not switches:
|
||||
return mapped, list(range(len(args)))
|
||||
|
||||
positional_args = []
|
||||
|
||||
args = list(args)
|
||||
while len(args) > 0:
|
||||
arg = args.pop(0)
|
||||
arg_i = 0
|
||||
while arg_i < len(args):
|
||||
arg = args[arg_i]
|
||||
arg_i += 1
|
||||
|
||||
# To facilitate better error messages, we expect that switches are always
|
||||
# specified as BareWords that start with "-" or ">". This lets us throw an
|
||||
@@ -170,71 +246,146 @@ def check_arg_spec(
|
||||
# any switches should be BareWords.
|
||||
contents = arg.contents
|
||||
if not (isinstance(arg, BareWord) and contents and contents[0] in {"-", ">"}):
|
||||
positional_args.append(arg)
|
||||
positional_args.append(arg_i - 1)
|
||||
continue
|
||||
|
||||
# TODO check required arguments
|
||||
if contents in args_allowed:
|
||||
if contents in switches:
|
||||
if contents in mapped and not switches[contents]["repeated"]:
|
||||
raise CommandArgError(
|
||||
f"duplicate argument for {command_name}: {contents}"
|
||||
)
|
||||
if switches[contents]["value"]:
|
||||
try:
|
||||
args.pop(0)
|
||||
except IndexError:
|
||||
arg_i += 1
|
||||
if arg_i > len(args):
|
||||
raise CommandArgError(
|
||||
f"invalid arguments for {command}: expected value after"
|
||||
f"invalid arguments for {command_name}: expected value after"
|
||||
f" {contents}"
|
||||
)
|
||||
if not switches[contents]["repeated"]:
|
||||
args_allowed.remove(contents)
|
||||
if contents in args_required:
|
||||
args_required.remove(contents)
|
||||
elif contents in arg_spec:
|
||||
raise CommandArgError(f"duplicate argument for {command}: {contents}")
|
||||
else:
|
||||
prefix_matches = []
|
||||
for switch in switches:
|
||||
if switch.startswith(contents):
|
||||
prefix_matches.append(switch)
|
||||
mapped.add(contents)
|
||||
continue
|
||||
|
||||
if len(prefix_matches) == 1:
|
||||
prefix_matches = []
|
||||
for switch in switches:
|
||||
if switch.startswith(contents):
|
||||
prefix_matches.append(switch)
|
||||
|
||||
if len(prefix_matches) == 1:
|
||||
raise CommandArgError(
|
||||
f"shortened argument for {command_name}: expand {contents} to"
|
||||
f" {prefix_matches[0]}"
|
||||
)
|
||||
|
||||
if len(prefix_matches) > 1:
|
||||
raise CommandArgError(
|
||||
f"ambiguous argument for {command_name}: {contents} could be any of"
|
||||
f" {', '.join(prefix_matches)}"
|
||||
)
|
||||
|
||||
raise CommandArgError(f"unrecognized argument for {command_name}: {contents}")
|
||||
|
||||
return mapped, positional_args
|
||||
|
||||
|
||||
def map_positionals(
|
||||
args: list[Node], spec: list[dict], command_name: str
|
||||
) -> list[list[int]]:
|
||||
"""Maps a list of nodes representing positional command arguments to the specific
|
||||
positional arguments of a command. spec represents the "positionals" entry of the
|
||||
spec for the given command.
|
||||
|
||||
The return value is a list whose entries correspond one-to-one to the entries in
|
||||
`args`. Each item in the return value is a list of indices into `spec`, indicating
|
||||
which argument(s) in the spec the corresponding argument maps to.
|
||||
|
||||
A given index into `spec` may appear multiple times in the list (e.g. if it's a
|
||||
variadic argument), and a list may contain more than one index for the mapping of an
|
||||
arg expansion.
|
||||
|
||||
If the arguments do not map correctly to the spec, this function raises
|
||||
CommandArgError.
|
||||
|
||||
Given a set of args and a spec, there may be multiple possible mappings. This
|
||||
function will return some mapping if one exists.
|
||||
|
||||
The `command_name` argument is used to generate descriptive error messages.
|
||||
"""
|
||||
|
||||
if len(args) == len(spec):
|
||||
# Self explanatory: a 1:1 match in argument count should be a legal mapping.
|
||||
return [[i] for i in range(len(args))]
|
||||
|
||||
mapping: list[list[int]] = []
|
||||
i = 0
|
||||
if len(args) > len(spec):
|
||||
# If there are more arguments than specified positionals, we map every argument
|
||||
# greedily and assign the extra # of arguments to the first variadic we find.
|
||||
extra = len(args) - len(spec)
|
||||
for arg in args:
|
||||
if i >= len(spec):
|
||||
# We never found a variadic to save us, raise an error.
|
||||
raise CommandArgError(
|
||||
f"shortened argument for {command}: expand {contents} to"
|
||||
f" {prefix_matches[0]}"
|
||||
f"too many arguments for {command_name}: got {len(args)}, expected"
|
||||
f" no more than {len(spec)}"
|
||||
)
|
||||
|
||||
if len(prefix_matches) > 1:
|
||||
raise CommandArgError(
|
||||
f"ambiguous argument for {command}: {contents} could be any of"
|
||||
f" {', '.join(prefix_matches)}"
|
||||
)
|
||||
mapping.append([i])
|
||||
if spec[i]["value"]["type"] == "variadic" and extra > 0:
|
||||
extra -= 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
raise CommandArgError(f"unrecognized argument for {command}: {contents}")
|
||||
return mapping
|
||||
|
||||
if len(args_required) > 1:
|
||||
raise CommandArgError(
|
||||
f"missing required arguments for {command}: {', '.join(args_required)}"
|
||||
)
|
||||
elif len(args_required) == 1:
|
||||
raise CommandArgError(
|
||||
f"missing required argument for {command}: {args_required.pop()}"
|
||||
)
|
||||
required = []
|
||||
for argspec in spec:
|
||||
if argspec["required"]:
|
||||
required.append(argspec["name"])
|
||||
num_required = len(required)
|
||||
|
||||
min_positionals = 0
|
||||
max_positionals: Optional[int] = 0
|
||||
for positional in arg_spec["positionals"]:
|
||||
if positional["value"]["type"] == "variadic":
|
||||
max_positionals = None
|
||||
if len(args) < num_required:
|
||||
# If there are fewer arguments than required positionals, we map only required
|
||||
# arguments and expand the first arg expansion we find to account for what's
|
||||
# missing.
|
||||
missing = num_required - len(args)
|
||||
for arg in args:
|
||||
while not spec[i]["required"]:
|
||||
i += 1
|
||||
|
||||
if positional["required"]:
|
||||
min_positionals += 1
|
||||
if max_positionals is not None:
|
||||
max_positionals += 1
|
||||
mapping.append([i])
|
||||
i += 1
|
||||
|
||||
check = check_count(
|
||||
command,
|
||||
min=min_positionals,
|
||||
max=max_positionals,
|
||||
args_name="positional args",
|
||||
)
|
||||
check(positional_args, None)
|
||||
if isinstance(arg, ArgExpansion):
|
||||
# Map missing arguments.
|
||||
while missing > 0:
|
||||
if spec[i]["required"]:
|
||||
mapping[-1] += [i]
|
||||
missing -= 1
|
||||
i += 1
|
||||
|
||||
return None
|
||||
if missing > 0:
|
||||
missing_names = ", ".join(required[-missing:])
|
||||
raise CommandArgError(
|
||||
f"missing required argument{'s' if missing > 1 else ''} for"
|
||||
f" {command_name}: {missing_names}"
|
||||
)
|
||||
|
||||
return mapping
|
||||
|
||||
optionals = len(args) - num_required
|
||||
for arg in args:
|
||||
# If our argument count falls somewhere in between the required and total
|
||||
# specified numbers of positionals, we map all required arguments and map as
|
||||
# many optionals as needed (as we find them).
|
||||
if not spec[i]["required"] and optionals > 0:
|
||||
mapping.append([i])
|
||||
i += 1
|
||||
optionals -= 1
|
||||
continue
|
||||
|
||||
while not spec[i]["required"]:
|
||||
i += 1
|
||||
|
||||
mapping.append([i])
|
||||
i += 1
|
||||
|
||||
return mapping
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
from importlib_metadata import entry_points
|
||||
import json
|
||||
import pathlib
|
||||
from typing import Dict, Optional
|
||||
from collections.abc import Sequence
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from types import ModuleType
|
||||
from typing import Optional
|
||||
|
||||
import voluptuous
|
||||
from importlib_metadata import EntryPoint, entry_points
|
||||
|
||||
from tclint.commands.schema import schema as command_schema
|
||||
from tclint.commands import builtin as _builtin
|
||||
from tclint.commands import schema
|
||||
|
||||
|
||||
class _PluginManager:
|
||||
def __init__(self):
|
||||
self._loaded = {}
|
||||
self._installed = {}
|
||||
self._loaded_specs = {}
|
||||
class PluginManager:
|
||||
def __init__(self, trust_uninstalled=False) -> None:
|
||||
self._loaded: dict[str, Optional[dict]] = {}
|
||||
self._installed: dict[str, EntryPoint] = {}
|
||||
self._loaded_specs: dict[pathlib.Path, Optional[dict]] = {}
|
||||
self._loaded_py: dict[pathlib.Path, Optional[dict]] = {}
|
||||
for plugin in entry_points(group="tclint.plugins"):
|
||||
if plugin.name in self._installed:
|
||||
print(f"Warning: found duplicate definitions for plugin {plugin.name}")
|
||||
self._installed[plugin.name] = plugin
|
||||
|
||||
def load(self, name: str) -> Optional[Dict]:
|
||||
self._trust_uninstalled = trust_uninstalled
|
||||
|
||||
def load(self, name: str) -> Optional[dict]:
|
||||
if name in self._loaded:
|
||||
return self._loaded[name]
|
||||
|
||||
@@ -27,7 +33,7 @@ class _PluginManager:
|
||||
self._loaded[name] = mod
|
||||
return mod
|
||||
|
||||
def load_from_spec(self, path: pathlib.Path) -> Optional[Dict]:
|
||||
def load_from_spec(self, path: pathlib.Path) -> Optional[dict]:
|
||||
if path in self._loaded_specs:
|
||||
return self._loaded_specs[path]
|
||||
|
||||
@@ -35,17 +41,21 @@ class _PluginManager:
|
||||
self._loaded_specs[path] = spec
|
||||
return spec
|
||||
|
||||
def _load_from_spec(self, path: pathlib.Path) -> Optional[Dict]:
|
||||
def _load_from_spec(self, path: pathlib.Path) -> Optional[dict]:
|
||||
try:
|
||||
with open(path.expanduser(), "r") as f:
|
||||
spec = json.load(f)
|
||||
except (FileNotFoundError, RuntimeError):
|
||||
# expanduser() may raise RuntimeError
|
||||
print(f"Warning: command spec {path} not found, skipping...")
|
||||
return None
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
print(f"Warning: {path} contains invalid JSON: {e}, skipping...")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Apply defaults and validate the spec.
|
||||
spec = command_schema(spec)
|
||||
spec = schema.schema(spec)
|
||||
except voluptuous.Invalid as e:
|
||||
print(f"Warning: invalid command spec {path}: {e}")
|
||||
return None
|
||||
@@ -67,8 +77,7 @@ class _PluginManager:
|
||||
|
||||
return module
|
||||
|
||||
def _load(self, name: str):
|
||||
module = self.get_mod(name)
|
||||
def _load_module(self, name, module):
|
||||
if module is None:
|
||||
print(f"Skipping requested plugin {name}")
|
||||
return None
|
||||
@@ -77,10 +86,72 @@ class _PluginManager:
|
||||
print(f"Warning: skipping plugin {name} since it does not define commands")
|
||||
return None
|
||||
|
||||
return getattr(module, "commands")
|
||||
spec = getattr(module, "commands")
|
||||
try:
|
||||
# Apply defaults and validate the spec.
|
||||
spec = schema.commands_schema(spec)
|
||||
except voluptuous.Invalid as e:
|
||||
print(f"Warning: invalid plugin {name}: {e}")
|
||||
return None
|
||||
|
||||
return spec
|
||||
|
||||
# TODO: we'll probably want to construct this in the tclint entry point and pass
|
||||
# it around rather than using a singleton instance, but this made for an easier
|
||||
# refactor.
|
||||
PluginManager = _PluginManager()
|
||||
def _load(self, name: str):
|
||||
module = self.get_mod(name)
|
||||
return self._load_module(name, module)
|
||||
|
||||
def load_from_py(self, path: pathlib.Path) -> Optional[dict]:
|
||||
if path in self._loaded_py:
|
||||
return self._loaded_py[path]
|
||||
|
||||
spec = self._load_from_py(path)
|
||||
self._loaded_py[path] = spec
|
||||
return spec
|
||||
|
||||
def _load_from_py(self, path: pathlib.Path) -> Optional[dict]:
|
||||
mod = None
|
||||
name = path.stem
|
||||
|
||||
# By default, reject paths to dynamic plugins. This restriction is designed to
|
||||
# make it explicit when tclint is executing external code.
|
||||
if not self._trust_uninstalled:
|
||||
print(
|
||||
f"Warning: skipping untrusted plugin {path}. If you trust the code at"
|
||||
" this path, re-run with --trust-plugins to load the plugin"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
spec = spec_from_file_location(name, path)
|
||||
if spec is not None:
|
||||
mod = module_from_spec(spec)
|
||||
if spec.loader is not None:
|
||||
spec.loader.exec_module(mod)
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: command spec {path} not found, skipping...")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Warning: error loading plugin {path}: {e}")
|
||||
return None
|
||||
|
||||
return self._load_module(name, mod)
|
||||
|
||||
def get_commands(self, plugins: Sequence[str | pathlib.Path]) -> dict:
|
||||
commands = {}
|
||||
commands.update(_builtin.commands)
|
||||
|
||||
for plugin in plugins:
|
||||
if isinstance(plugin, str):
|
||||
plugin_commands = self.load(plugin)
|
||||
elif isinstance(plugin, pathlib.Path):
|
||||
if plugin.suffix == ".py":
|
||||
plugin_commands = self.load_from_py(plugin)
|
||||
else:
|
||||
plugin_commands = self.load_from_spec(plugin)
|
||||
else:
|
||||
raise TypeError(f"Plugins must be strings or paths, got {type(plugin)}")
|
||||
|
||||
if plugin_commands is not None:
|
||||
commands.update(plugin_commands)
|
||||
|
||||
return commands
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
from voluptuous import Schema, Optional, Or, Self
|
||||
|
||||
from voluptuous import Optional, Or, Schema, Self
|
||||
|
||||
# Need to define this as a Schema with required=True to ensure that this requirement
|
||||
# persists through the Or in the main schema definition.
|
||||
@@ -9,7 +10,12 @@ _command_args = Schema(
|
||||
{
|
||||
"name": str,
|
||||
"required": bool,
|
||||
"value": Or({"type": "any"}, {"type": "variadic"}),
|
||||
"value": Or(
|
||||
{"type": "any"},
|
||||
{"type": "variadic"},
|
||||
{"type": "script"},
|
||||
{"type": "expression"},
|
||||
),
|
||||
}
|
||||
],
|
||||
Optional("switches", default={}): {
|
||||
|
||||
Reference in New Issue
Block a user