add updates libs

This commit is contained in:
Christoph Brandau
2026-06-18 17:21:58 +02:00
parent 91ce762570
commit 41f331d4c4
150 changed files with 8249 additions and 2550 deletions
+2 -35
View File
@@ -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"]
+233 -117
View File
@@ -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),
})
+256 -105
View File
@@ -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
+90 -19
View File
@@ -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
+8 -2
View File
@@ -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={}): {