diff --git a/CHANGELOG.md b/CHANGELOG.md index dccd7886..fceb5ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 更新日志 +## Alconna 1.7.32 + +### 改进 +- 可以选择配置禁用哪些内置选项 +- 自定义的与内置选项名称有冲突的选项 (例如 --help) 在禁用内置选项后能正常解析 + ## Alconna 1.7.31 ### 改进 diff --git a/src/arclet/alconna/__init__.py b/src/arclet/alconna/__init__.py index 72dfa2ac..7dd9f7d8 100644 --- a/src/arclet/alconna/__init__.py +++ b/src/arclet/alconna/__init__.py @@ -49,7 +49,7 @@ from .typing import UnpackVar as UnpackVar from .typing import Up as Up -__version__ = "1.7.31" +__version__ = "1.7.32" # backward compatibility Arpamar = Arparma diff --git a/src/arclet/alconna/_internal/_analyser.py b/src/arclet/alconna/_internal/_analyser.py index 11f88743..b79c691e 100644 --- a/src/arclet/alconna/_internal/_analyser.py +++ b/src/arclet/alconna/_internal/_analyser.py @@ -10,7 +10,7 @@ from ..action import Action from ..args import Args from ..arparma import Arparma -from ..base import Option, Subcommand +from ..base import Option, Subcommand, Help, Shortcut, Completion from ..completion import comp_ctx from ..config import config from ..exceptions import ArgumentMissing, FuzzyMatchSuccess, ParamsUnmatched, PauseTriggered, SpecialOptionTriggered @@ -71,7 +71,7 @@ def default_compiler(analyser: SubAnalyser, pids: set[str]): pids (set[str]): 节点名集合 """ for opts in analyser.command.options: - if isinstance(opts, Option): + if isinstance(opts, Option) and not isinstance(opts, (Help, Shortcut, Completion)): if opts.compact or opts.action.type == 2 or not set(analyser.command.separators).issuperset(opts.separators): # noqa: E501 analyser.compact_params.append(opts) _compile_opts(opts, analyser.compile_params) # type: ignore @@ -376,10 +376,10 @@ def analyse(self, argv: Argv[TDC]) -> Arparma[TDC] | None: return _SPECIAL[sot.args[0]](self, argv) except (ParamsUnmatched, ArgumentMissing) as e1: if (rest := argv.release()) and isinstance(rest[-1], str): - if rest[-1] in argv.completion_names: + if rest[-1] in argv.completion_names and "completion" not in argv.namespace.disable_builtin_options: argv.bak_data[-1] = argv.bak_data[-1][: -len(rest[-1])].rstrip() return handle_completion(self, argv) - if handler := argv.special.get(rest[-1]): + if (handler := argv.special.get(rest[-1])) and handler not in argv.namespace.disable_builtin_options: return _SPECIAL[handler](self, argv) if comp_ctx.get(None): if isinstance(e1, ParamsUnmatched): diff --git a/src/arclet/alconna/_internal/_argv.py b/src/arclet/alconna/_internal/_argv.py index de9a2eff..c08bb5a0 100644 --- a/src/arclet/alconna/_internal/_argv.py +++ b/src/arclet/alconna/_internal/_argv.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import InitVar, dataclass, field +from dataclasses import dataclass, field from typing import Any, Callable, ClassVar, Generic, Iterable from typing_extensions import Self @@ -17,7 +17,7 @@ class Argv(Generic[TDC]): """命令行参数""" - namespace: InitVar[Namespace] = field(default=config.default_namespace) + namespace: Namespace = field(default=config.default_namespace) fuzzy_match: bool = field(default=False) """当前命令是否模糊匹配""" preprocessors: dict[type, Callable[..., Any]] = field(default_factory=dict) @@ -57,15 +57,15 @@ class Argv(Generic[TDC]): _cache: ClassVar[dict[type, dict[str, Any]]] = {} - def __post_init__(self, namespace: Namespace): + def __post_init__(self): self.reset() self.special: dict[str, str] = {} self.special.update( - [(i, "help") for i in namespace.builtin_option_name["help"]] - + [(i, "completion") for i in namespace.builtin_option_name["completion"]] - + [(i, "shortcut") for i in namespace.builtin_option_name["shortcut"]] + [(i, "help") for i in self.namespace.builtin_option_name["help"]] + + [(i, "completion") for i in self.namespace.builtin_option_name["completion"]] + + [(i, "shortcut") for i in self.namespace.builtin_option_name["shortcut"]] ) - self.completion_names = namespace.builtin_option_name["completion"] + self.completion_names = self.namespace.builtin_option_name["completion"] if __cache := self.__class__._cache.get(self.__class__, {}): self.preprocessors.update(__cache.get("preprocessors") or {}) self.filter_out.extend(__cache.get("filter_out") or []) diff --git a/src/arclet/alconna/_internal/_handlers.py b/src/arclet/alconna/_internal/_handlers.py index 394aa660..85f1c622 100644 --- a/src/arclet/alconna/_internal/_handlers.py +++ b/src/arclet/alconna/_internal/_handlers.py @@ -57,7 +57,8 @@ def step_varpos(argv: Argv, args: Args, result: dict[str, Any]): for _ in range(loop): may_arg, _str = argv.next(arg.separators) if _str and may_arg in argv.special: - raise SpecialOptionTriggered(argv.special[may_arg]) + if argv.special[may_arg] not in argv.namespace.disable_builtin_options: + raise SpecialOptionTriggered(argv.special[may_arg]) if not may_arg or (_str and may_arg in argv.param_ids): argv.rollback(may_arg) break @@ -93,7 +94,8 @@ def step_varkey(argv: Argv, args: Args, result: dict[str, Any]): for _ in range(loop): may_arg, _str = argv.next(arg.separators) if _str and may_arg in argv.special: - raise SpecialOptionTriggered(argv.special[may_arg]) + if argv.special[may_arg] not in argv.namespace.disable_builtin_options: + raise SpecialOptionTriggered(argv.special[may_arg]) if not may_arg or (_str and may_arg in argv.param_ids) or not _str: argv.rollback(may_arg) break @@ -127,7 +129,8 @@ def step_keyword(argv: Argv, args: Args, result: dict[str, Any]): while count < target: may_arg, _str = argv.next(tuple(kwonly_seps)) if _str and may_arg in argv.special: - raise SpecialOptionTriggered(argv.special[may_arg]) + if argv.special[may_arg] not in argv.namespace.disable_builtin_options: + raise SpecialOptionTriggered(argv.special[may_arg]) if not may_arg or not _str: argv.rollback(may_arg) break @@ -182,7 +185,8 @@ def analyse_args(argv: Argv, args: Args) -> dict[str, Any]: argv.context = arg may_arg, _str = argv.next(arg.separators) if _str and may_arg in argv.special: - raise SpecialOptionTriggered(argv.special[may_arg]) + if argv.special[may_arg] not in argv.namespace.disable_builtin_options: + raise SpecialOptionTriggered(argv.special[may_arg]) if _str and may_arg in argv.param_ids and arg.optional: if (de := arg.field.default) is not None: result[arg.name] = None if de is Empty else de @@ -353,9 +357,10 @@ def analyse_param(analyser: SubAnalyser, argv: Argv, seps: tuple[str, ...] | Non """ _text, _str = argv.next(seps, move=False) if _str and _text in argv.special: - if _text in argv.completion_names: - argv.bak_data[argv.current_index] = argv.bak_data[argv.current_index].replace(_text, "") - raise SpecialOptionTriggered(argv.special[_text]) + if argv.special[_text] not in argv.namespace.disable_builtin_options: + if _text in argv.completion_names: + argv.bak_data[argv.current_index] = argv.bak_data[argv.current_index].replace(_text, "") + raise SpecialOptionTriggered(argv.special[_text]) if not _str or not _text: _param = None elif _text in analyser.compile_params: diff --git a/src/arclet/alconna/base.py b/src/arclet/alconna/base.py index 3be33380..9cb978d5 100644 --- a/src/arclet/alconna/base.py +++ b/src/arclet/alconna/base.py @@ -345,4 +345,16 @@ def add(self, opt: Option | Subcommand) -> Self: return self -__all__ = ["CommandNode", "Option", "Subcommand"] +class Help(Option): + def _calc_hash(self): + return hash("$ALCONNA_BUILTIN_OPTION_HELP") + + +class Shortcut(Option): + def _calc_hash(self): + return hash("$ALCONNA_BUILTIN_OPTION_SHORTCUT") + + +class Completion(Option): + def _calc_hash(self): + return hash("$ALCONNA_BUILTIN_OPTION_COMPLETION") diff --git a/src/arclet/alconna/config.py b/src/arclet/alconna/config.py index a4fdca3b..0e28f9f9 100644 --- a/src/arclet/alconna/config.py +++ b/src/arclet/alconna/config.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, ContextManager, TypedDict +from typing import TYPE_CHECKING, Any, Callable, ContextManager, Literal, TypedDict from tarina import lang @@ -39,6 +39,7 @@ class Namespace: """默认是否抛出异常""" enable_message_cache: bool = field(default=True) """默认是否启用消息缓存""" + disable_builtin_options: set[Literal["help", "shortcut", "completion"]] = field(default_factory=set) builtin_option_name: OptionNames = field( default_factory=lambda: { "help": {"--help", "-h"}, diff --git a/src/arclet/alconna/core.py b/src/arclet/alconna/core.py index 330c75ef..76b9e1b3 100644 --- a/src/arclet/alconna/core.py +++ b/src/arclet/alconna/core.py @@ -13,7 +13,7 @@ from ._internal._analyser import Analyser, TCompile from .args import Arg, Args from .arparma import Arparma, ArparmaBehavior, requirement_handler -from .base import Option, Subcommand +from .base import Option, Subcommand, Help, Shortcut, Completion from .config import Namespace, config from .duplication import Duplication from .exceptions import ExecuteFailed, NullMessage @@ -37,15 +37,18 @@ def handle_argv(): def add_builtin_options(options: list[Option | Subcommand], ns: Namespace) -> None: - options.append(Option("|".join(ns.builtin_option_name["help"]), help_text=lang.require("builtin", "option_help"))) # noqa: E501 - options.append( - Option( - "|".join(ns.builtin_option_name["shortcut"]), - Args["action?", "delete|list"]["name?", str]["command", str, "$"], - help_text=lang.require("builtin", "option_shortcut"), + if "help" not in ns.disable_builtin_options: + options.append(Help("|".join(ns.builtin_option_name["help"]), help_text=lang.require("builtin", "option_help"))) # noqa: E501 + if "shortcut" not in ns.disable_builtin_options: + options.append( + Shortcut( + "|".join(ns.builtin_option_name["shortcut"]), + Args["action?", "delete|list"]["name?", str]["command", str, "$"], + help_text=lang.require("builtin", "option_shortcut"), + ) ) - ) - options.append(Option("|".join(ns.builtin_option_name["completion"]), help_text=lang.require("builtin", "option_completion"))) # noqa: E501 + if "completion" not in ns.disable_builtin_options: + options.append(Completion("|".join(ns.builtin_option_name["completion"]), help_text=lang.require("builtin", "option_completion"))) # noqa: E501 @dataclass(init=True, unsafe_hash=True) diff --git a/tests/core_test.py b/tests/core_test.py index f3c948ae..58f7b952 100644 --- a/tests/core_test.py +++ b/tests/core_test.py @@ -13,6 +13,7 @@ MultiVar, Option, Subcommand, + namespace, ) @@ -417,7 +418,7 @@ def test_shortcut(): alc16 = Alconna("core16", Args["foo", int], Option("bar", Args["baz", str])) assert alc16.parse("core16 123 bar abcd").matched is True # 构造体缩写传入;{i} 将被可能的正则匹配替换 - alc16.shortcut("TEST(\d+)(.+)", {"args": ["{0}", "bar {1}"]}) + alc16.shortcut(r"TEST(\d+)(.+)", {"args": ["{0}", "bar {1}"]}) res = alc16.parse("TEST123aa") assert res.matched is True assert res.foo == 123 @@ -431,7 +432,7 @@ def test_shortcut(): res2 = alc16.parse("TEST3 442") assert res2.foo == 442 # 指令缩写也支持正则 - alc16.parse("core16 --shortcut TESTa4(\d+) 'core16 {0}'") + alc16.parse(r"core16 --shortcut TESTa4(\d+) 'core16 {0}'") res3 = alc16.parse("TESTa4257") assert res3.foo == 257 alc16.parse("core16 --shortcut TESTac 'core16 2{%0}'") @@ -851,5 +852,28 @@ def test_tips(): assert str(core27.parse("core27").error_info) == "参数 arg1 丢失" +def test_disable_builtin_option(): + with namespace("test"): + core28 = Alconna("core28") + core28_1 = Alconna("core28_1", Args["text", MultiVar(str)]) + core28.namespace_config.disable_builtin_options.add("shortcut") + + res = core28.parse("core28 --shortcut 123 test") + assert not res.matched + assert str(res.error_info) == "参数 --shortcut 匹配失败" + + res1 = core28_1.parse("core28_1 --shortcut 123 test") + assert res1.matched + assert res1.query("text") == ("--shortcut", "123", "test") + + with namespace("test1") as ns: + ns.disable_builtin_options.add("help") + core28_2 = Alconna("core28_2", Option("--help")) + + res2 = core28_2.parse("core28_2 --help") + assert res2.matched + assert res2.find("help") + + if __name__ == "__main__": pytest.main([__file__, "-vs"])