diff --git a/CHANGELOG.md b/CHANGELOG.md index 895f2d7e..fae55a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # 更新日志 +## Alconna 1.8.6 + +### 改进 + +- 帮助信息现在会显示命令的快捷指令 + +### 新增 + +- `Alconna.shortcut` 新增参数 `humanized`,用于指定快捷指令的可读性文本以供显示 + + ```python + from arclet.alconna import Alconna, Args + + alc = Alconna("test", Args["foo", int]) + alc.shortcut("t(\d+)", command="test {0}", humanized="t[数字]") + ``` + +- `CommandMeta` 新增属性 `hide_shortcut`, 用于在帮助信息里隐藏命令的快捷指令 + ## Alconna 1.8.5 ### 修复 diff --git a/src/arclet/alconna/__init__.py b/src/arclet/alconna/__init__.py index 5068765c..5b0c6646 100644 --- a/src/arclet/alconna/__init__.py +++ b/src/arclet/alconna/__init__.py @@ -50,7 +50,7 @@ from .typing import UnpackVar as UnpackVar from .typing import Up as Up -__version__ = "1.8.5" +__version__ = "1.8.6" # backward compatibility AnyOne = ANY diff --git a/src/arclet/alconna/core.py b/src/arclet/alconna/core.py index 53d6fa02..5254c052 100644 --- a/src/arclet/alconna/core.py +++ b/src/arclet/alconna/core.py @@ -210,11 +210,16 @@ def get_shortcuts(self) -> list[str]: shortcuts = command_manager.get_shortcut(self) for key, short in shortcuts.items(): if isinstance(short, InnerShortcutArgs): - result.append(key + (" ...args" if short.fuzzy else "")) + prefixes = f"[{'│'.join(short.prefixes)}]" if short.prefixes else "" + result.append(prefixes + key + (" ...args" if short.fuzzy else "")) else: result.append(key) return result + def _get_shortcuts(self): + """返回该命令注册的快捷命令""" + return command_manager.get_shortcut(self) + @overload def shortcut(self, key: str, args: ShortcutArgs | None = None) -> str: """操作快捷命令 @@ -241,6 +246,7 @@ def shortcut( fuzzy: bool = True, prefix: bool = False, wrapper: ShortcutRegWrapper | None = None, + humanized: str | None = None, ) -> str: """操作快捷命令 @@ -251,6 +257,7 @@ def shortcut( fuzzy (bool, optional): 是否允许命令后随参数, 默认为 `True` prefix (bool, optional): 是否调用时保留指令前缀, 默认为 `False` wrapper (ShortcutRegWrapper, optional): 快捷指令的正则匹配结果的额外处理函数, 默认为 `None` + humanized (str, optional): 快捷指令的人类可读描述, 默认为 `None` Returns: str: 操作结果 diff --git a/src/arclet/alconna/formatter.py b/src/arclet/alconna/formatter.py index 5b76a120..29e2c4f2 100644 --- a/src/arclet/alconna/formatter.py +++ b/src/arclet/alconna/formatter.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypedDict from weakref import WeakKeyDictionary from nepattern import ANY, AnyString @@ -9,7 +9,7 @@ from .args import Arg, Args from .base import Option, Subcommand -from .typing import AllParam +from .typing import AllParam, InnerShortcutArgs if TYPE_CHECKING: from .core import Alconna @@ -61,6 +61,13 @@ def ensure_node(targets: list[str], options: list[Option | Subcommand]): return ensure_node(targets, options) +class TraceHead(TypedDict): + name: str + description: str + usage: str | None + example: str | None + + @dataclass(eq=True) class Trace: """存放命令节点数据的结构 @@ -68,10 +75,11 @@ class Trace: 该结构用于存放命令节点的数据,包括命令节点的头部、参数、分隔符和主体。 """ - head: dict[str, Any] + head: TraceHead args: Args separators: tuple[str, ...] body: list[Option | Subcommand] + shortcuts: dict[str, Any] class TextFormatter: @@ -81,7 +89,7 @@ class TextFormatter: """ def __init__(self): - self.data = WeakKeyDictionary() + self.data: "WeakKeyDictionary[Alconna, Trace]" = WeakKeyDictionary() self.ignore_names = set() def add(self, base: Alconna): @@ -92,7 +100,6 @@ def add(self, base: Alconna): res = Trace( { "name": base.header_display, - "prefix": [], "description": base.meta.description, "usage": base.meta.usage, "example": base.meta.example, @@ -100,10 +107,17 @@ def add(self, base: Alconna): base.args, base.separators, base.options.copy(), + {} if base.meta.hide_shortcut else base._get_shortcuts(), ) self.data[base] = res return self + def update_shortcut(self, base: Alconna): + """更新目标命令的快捷指令""" + if not base.meta.hide_shortcut: + self.data[base].shortcuts = base._get_shortcuts() + return self + def remove(self, base: Alconna): """移除目标命令""" self.data.pop(base) @@ -138,15 +152,15 @@ def _handle(trace: Trace): _opts.append(i) _visited.add(i) return self.format( - Trace({"name": _parts[-1], 'prefix': [], 'description': _parts[-1]}, Args(), trace.separators, _opts) # noqa: E501 + Trace({"name": _parts[-1], 'description': _parts[-1], 'example': None, 'usage': None}, Args(), trace.separators, _opts, {}) # noqa: E501 ) if isinstance(_cache, Option): return self.format( - Trace({"name": "│".join(_cache.aliases), "prefix": [], "description": _cache.help_text}, _cache.args, _cache.separators, []) # noqa: E501 + Trace({"name": "│".join(_cache.aliases), "description": _cache.help_text, 'example': None, 'usage': None}, _cache.args, _cache.separators, [], {}) # noqa: E501 ) if isinstance(_cache, Subcommand): return self.format( - Trace({"name": "│".join(_cache.aliases), "prefix": [], "description": _cache.help_text}, _cache.args, _cache.separators, _cache.options) # noqa: E501 + Trace({"name": "│".join(_cache.aliases), "description": _cache.help_text, 'example': None, 'usage': None}, _cache.args, _cache.separators, _cache.options, {}) # noqa: E501 ) return self.format(trace) @@ -158,16 +172,19 @@ def format(self, trace: Trace) -> str: Args: trace (Trace): 命令节点数据 """ - title, desc, usage, example = self.header(trace.head, trace.separators) + title, desc, usage, example = self.header(trace.head) param = self.parameters(trace.args) body = self.body(trace.body) - res = f"{title} {param}\n{desc}" + res = f"{title}{trace.separators[0]}{param}\n{desc}" + shortcuts = self.shortcut(trace.shortcuts) if usage: res += f"\n{usage}" if body: res += f"\n\n{body}" if example: res += f"\n{example}" + if shortcuts: + res += f"\n{shortcuts}" return res def param(self, parameter: Arg) -> str: @@ -212,20 +229,16 @@ def parameters(self, args: Args) -> str: else res ) - def header(self, root: dict[str, Any], separators: tuple[str, ...]) -> tuple[str, str, str, str]: + def header(self, root: TraceHead) -> tuple[str, str, str, str]: """头部节点的描述 Args: - root (dict[str, Any]): 头部节点数据 - separators (tuple[str, ...]): 分隔符 + root (TraceHead): 头部节点数据 """ - help_string = f"{desc}" if (desc := root.get("description")) else "" + help_string = f"{desc}" if (desc := root["description"]) else "" usage = f"{lang.require('format', 'usage')}:\n{usage}" if (usage := root.get("usage")) else "" example = f"{lang.require('format', 'example')}:\n{example}" if (example := root.get("example")) else "" - prefixs = f"[{''.join(map(str, prefixs))}]" if (prefixs := root.get("prefix", [])) != [] else "" - cmd = f"{prefixs}{root.get('name', '')}" - command_string = cmd or (root["name"] + separators[0]) - return command_string, help_string, usage, example + return root["name"], help_string, usage, example def opt(self, node: Option) -> str: """对单个选项的描述""" @@ -263,5 +276,19 @@ def body(self, parts: list[Option | Subcommand]) -> str: subcommand_help = f"{lang.require('format', 'subcommands')}:\n" if subcommand_string else "" return f"{subcommand_help}{subcommand_string}{option_help}{option_string}" + def shortcut(self, shortcuts: dict[str, Any]) -> str: + """快捷指令的描述""" + if not shortcuts: + return "" + result = [] + for key, short in shortcuts.items(): + if isinstance(short, InnerShortcutArgs): + _key = key + (" ...args" if short.fuzzy else "") + prefixes = f"[{'│'.join(short.prefixes)}]" if short.prefixes else "" + result.append(f"'{prefixes}{_key}' => {prefixes}{short.command} {' '.join(short.args)}") + else: + result.append(f"'{key}' => {short.origin!r}") + return f"{lang.require('format', 'shortcuts')}:\n" + "\n".join(result) + __all__ = ["TextFormatter", "Trace"] diff --git a/src/arclet/alconna/i18n/en-US.json b/src/arclet/alconna/i18n/en-US.json index 00c57003..89781cf5 100644 --- a/src/arclet/alconna/i18n/en-US.json +++ b/src/arclet/alconna/i18n/en-US.json @@ -88,6 +88,7 @@ "notice": "Notice", "usage": "Usage", "example": "Example", + "shortcuts": "Shortcut Commands", "subcommands": "Commands", "subcommands.subs": "Commands in above command", "subcommands.opts": "Options in above command", diff --git a/src/arclet/alconna/i18n/zh-CN.json b/src/arclet/alconna/i18n/zh-CN.json index d2529c63..8f807969 100644 --- a/src/arclet/alconna/i18n/zh-CN.json +++ b/src/arclet/alconna/i18n/zh-CN.json @@ -87,6 +87,7 @@ "notice": "注释", "usage": "用法", "example": "使用示例", + "shortcuts": "快捷命令", "subcommands": "可用的子命令有", "subcommands.subs": "该子命令内可用的子命令有", "subcommands.opts": "该子命令内可用的选项有", diff --git a/src/arclet/alconna/manager.py b/src/arclet/alconna/manager.py index 8357371f..426f37f4 100644 --- a/src/arclet/alconna/manager.py +++ b/src/arclet/alconna/manager.py @@ -8,7 +8,7 @@ import weakref from copy import copy from datetime import datetime -from typing import TYPE_CHECKING, Any, Match, MutableSet, Union, Callable +from typing import TYPE_CHECKING, Any, Match, MutableSet, Union from weakref import WeakKeyDictionary, WeakValueDictionary from tarina import LRU, lang @@ -40,7 +40,7 @@ class CommandManager: __argv: WeakKeyDictionary[Alconna, Argv] __abandons: list[Alconna] __record: LRU[int, Arparma] - __shortcuts: dict[str, dict[str, Union[Arparma, InnerShortcutArgs]]] + __shortcuts: dict[str, tuple[dict[str, Union[Arparma, InnerShortcutArgs]], dict[str, Union[Arparma, InnerShortcutArgs]]]] def __init__(self): self.cache_path = f"{__file__.replace('manager.py', '')}manager_cache.db" @@ -206,26 +206,35 @@ def add_shortcut(self, target: Alconna, key: str, source: Arparma | ShortcutArgs """ namespace, name = self._command_part(target.path) argv = self.resolve(target) - _shortcut = self.__shortcuts.setdefault(f"{namespace}.{name}", {}) + _shortcut = self.__shortcuts.setdefault(f"{namespace}.{name}", ({}, {})) if isinstance(source, dict): + humanize = source.pop("humanized", None) if source.get("prefix", False) and target.prefixes: + prefixes = [] out = [] for prefix in target.prefixes: if not isinstance(prefix, str): continue - _shortcut[f"{re.escape(prefix)}{key}"] = InnerShortcutArgs( + prefixes.append(prefix) + _shortcut[1][f"{re.escape(prefix)}{key}"] = InnerShortcutArgs( **{**source, "command": argv.converter(prefix + source.get("command", str(target.command)))} ) out.append( lang.require("shortcut", "add_success").format(shortcut=f"{prefix}{key}", target=target.path) ) + _shortcut[0][humanize or key] = InnerShortcutArgs( + **{**source, "command": argv.converter(source.get("command", str(target.command))), "prefixes": prefixes} + ) + target.formatter.update_shortcut(target) return "\n".join(out) - _shortcut[key] = InnerShortcutArgs( + _shortcut[0][humanize or key] = _shortcut[1][key] = InnerShortcutArgs( **{**source, "command": argv.converter(source.get("command", str(target.command)))} ) + target.formatter.update_shortcut(target) return lang.require("shortcut", "add_success").format(shortcut=key, target=target.path) elif source.matched: - _shortcut[key] = source + _shortcut[0][key] = _shortcut[1][key] = source + target.formatter.update_shortcut(target) return lang.require("shortcut", "add_success").format(shortcut=key, target=target.path) else: raise ValueError(lang.require("manager", "incorrect_shortcut").format(target=f"{key}")) @@ -242,7 +251,10 @@ def get_shortcut(self, target: Alconna[TDC]) -> dict[str, Union[Arparma[TDC], In namespace, name = self._command_part(target.path) if target not in self.__analysers: raise ValueError(lang.require("manager", "undefined_command").format(target=f"{namespace}.{name}")) - return self.__shortcuts.get(f"{namespace}.{name}", {}) + shortcuts = self.__shortcuts.get(f"{namespace}.{name}", {}) + if not shortcuts: + return {} + return shortcuts[0] def find_shortcut( self, target: Alconna[TDC], data: list @@ -261,15 +273,15 @@ def find_shortcut( raise ValueError(lang.require("manager", "undefined_command").format(target=f"{namespace}.{name}")) query: str = data.pop(0) while True: - if query in _shortcut: - return data, _shortcut[query], None - for key, args in _shortcut.items(): + if query in _shortcut[1]: + return data, _shortcut[1][query], None + for key, args in _shortcut[1].items(): if isinstance(args, InnerShortcutArgs) and args.fuzzy and (mat := re.match(f"^{key}", query)): if len(query) > mat.span()[1]: data.insert(0, query[mat.span()[1]:]) return data, args, mat elif mat := re.fullmatch(key, query): - return data, _shortcut[key], mat + return data, _shortcut[1][key], mat if not data: break next_data = data.pop(0) @@ -287,7 +299,8 @@ def delete_shortcut(self, target: Alconna, key: str | None = None): raise ValueError(lang.require("manager", "undefined_command").format(target=f"{namespace}.{name}")) if key: try: - del _shortcut[key] + _shortcut[0].pop(key, None) + del _shortcut[1][key] return lang.require("shortcut", "delete_success").format(shortcut=key, target=target.path) except KeyError as e: raise ValueError( @@ -438,7 +451,7 @@ def __repr__(self): + "Commands:\n" + f"[{', '.join([cmd.path for cmd in self.get_commands()])}]" + "\nShortcuts:\n" - + "\n".join([f" {k} => {v}" for short in self.__shortcuts.values() for k, v in short.items()]) + + "\n".join([f" {k} => {v}" for short in self.__shortcuts.values() for k, v in short[0].items()]) + "\nRecords:\n" + "\n".join([f" [{k}]: {v[1].origin}" for k, v in enumerate(self.__record.items()[:20])]) + "\nDisabled Commands:\n" diff --git a/src/arclet/alconna/model.py b/src/arclet/alconna/model.py index 358a8b80..517f733e 100644 --- a/src/arclet/alconna/model.py +++ b/src/arclet/alconna/model.py @@ -6,8 +6,8 @@ @dataclass(init=False, eq=True) class Sentence: __slots__ = ("name",) - __str__ = lambda self: self.name - __repr__ = lambda self: self.name + __str__ = lambda self: self.name # type: ignore + __repr__ = lambda self: self.name # type: ignore def __init__(self, name): self.name = name diff --git a/src/arclet/alconna/typing.py b/src/arclet/alconna/typing.py index fcd06bed..f9f9da0d 100644 --- a/src/arclet/alconna/typing.py +++ b/src/arclet/alconna/typing.py @@ -41,6 +41,8 @@ class ShortcutArgs(TypedDict): """是否调用时保留指令前缀""" wrapper: NotRequired[ShortcutRegWrapper] """快捷指令的正则匹配结果的额外处理函数""" + humanized: NotRequired[str] + """快捷指令的人类可读描述""" DEFAULT_WRAPPER = lambda slot, content: content @@ -51,9 +53,10 @@ class InnerShortcutArgs: args: list[Any] fuzzy: bool prefix: bool + prefixes: list[str] wrapper: ShortcutRegWrapper - __slots__ = ("command", "args", "fuzzy", "prefix", "wrapper") + __slots__ = ("command", "args", "fuzzy", "prefix", "prefixes", "wrapper") def __init__( self, @@ -61,12 +64,14 @@ def __init__( args: list[Any] | None = None, fuzzy: bool = True, prefix: bool = False, + prefixes: list[str] | None = None, wrapper: ShortcutRegWrapper | None = None ): self.command = command self.args = args or [] self.fuzzy = fuzzy self.prefix = prefix + self.prefixes = prefixes or [] self.wrapper = wrapper or DEFAULT_WRAPPER def __repr__(self): @@ -101,6 +106,8 @@ class CommandMeta: "命令是否抛出异常" hide: bool = field(default=False) "命令是否对manager隐藏" + hide_shortcut: bool = field(default=False) + "命令的快捷指令是否在help信息中隐藏" keep_crlf: bool = field(default=False) "命令是否保留换行字符" compact: bool = field(default=False) diff --git a/tests/core_test.py b/tests/core_test.py index a9ce8354..a236f620 100644 --- a/tests/core_test.py +++ b/tests/core_test.py @@ -506,13 +506,18 @@ def wrapper(slot, content): return content alc16_6 = Alconna("core16_6", Args["bar", str]) - alc16_6.shortcut("test(?P.+)?", wrapper=wrapper, arguments=["{bar}"]) + alc16_6.shortcut("test(?P.+)?", fuzzy=False, wrapper=wrapper, arguments=["{bar}"]) assert alc16_6.parse("testabc").bar == "abc" with output_manager.capture("core16_6") as cap: output_manager.set_action(lambda x: x, "core16_6") alc16_6.parse("testhelp") - assert cap["output"] == "core16_6 \nUnknown" + assert cap["output"] == """\ +core16_6 +Unknown +快捷命令: +'test(?P.+)?' => core16_6 {bar}\ +""" alc16_7 = Alconna("core16_7", Args["bar", str]) alc16_7.shortcut("test 123", {"args": ["abc"]})