mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat(cli): normalize plugin init skeletons
Add interactive plugin init prompts and normalize generated plugin names to the astrbot_plugin_ convention. Update CLI tests for the new skeleton layout and ignore generated plugin directories in git and coverage tooling. Also include related runtime logging adjustments from the current worktree.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,3 +47,4 @@ plugins/.venv/
|
||||
*.iml
|
||||
uv.lock
|
||||
/astrBot/
|
||||
plugins/
|
||||
@@ -73,6 +73,7 @@ omit = [
|
||||
"*/tests/*",
|
||||
"*/__pycache__/*",
|
||||
"*/_legacy_api.py",
|
||||
"*/plugins/*"
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import typing
|
||||
@@ -45,6 +46,9 @@ BUILD_EXCLUDED_DIRS = {
|
||||
BUILD_EXCLUDED_FILES = {
|
||||
".astrbot-worker-state.json",
|
||||
}
|
||||
INIT_DEFAULT_AUTHOR = ""
|
||||
INIT_DEFAULT_PYTHON_VERSION = "3.12"
|
||||
INIT_DEFAULT_VERSION = "1.0.0"
|
||||
|
||||
|
||||
class _CliPluginValidationError(RuntimeError):
|
||||
@@ -262,12 +266,6 @@ def _handle_dev_meta_command(command: str, state: dict[str, Any]) -> bool:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _slugify_plugin_name(value: str) -> str:
|
||||
slug = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_").lower()
|
||||
return slug or "my_plugin"
|
||||
|
||||
|
||||
def _class_name_for_plugin(value: str) -> str:
|
||||
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", value) if part]
|
||||
if not parts:
|
||||
@@ -280,18 +278,62 @@ def _sanitize_build_part(value: str) -> str:
|
||||
return sanitized or "artifact"
|
||||
|
||||
|
||||
def _render_init_plugin_yaml(*, plugin_name: str, display_name: str) -> str:
|
||||
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
def _yaml_string(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _normalize_init_plugin_name(value: str) -> str:
|
||||
normalized = re.sub(r"[\s-]+", "_", value.strip())
|
||||
normalized = re.sub(r"[^a-zA-Z0-9_]+", "_", normalized)
|
||||
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
|
||||
if not normalized:
|
||||
normalized = "my_plugin"
|
||||
|
||||
prefix = "astrbot_plugin_"
|
||||
if normalized == "astrbot_plugin":
|
||||
return f"{prefix}my_plugin"
|
||||
if normalized.startswith(prefix):
|
||||
suffix = normalized.removeprefix(prefix).strip("_") or "my_plugin"
|
||||
return f"{prefix}{suffix}"
|
||||
return f"{prefix}{normalized}"
|
||||
|
||||
|
||||
def _prompt_required_init_name() -> str:
|
||||
while True:
|
||||
value = click.prompt("插件名字", default="", show_default=False).strip()
|
||||
if value:
|
||||
return value
|
||||
click.echo("插件名字不能为空")
|
||||
|
||||
|
||||
def _collect_init_inputs(name: str | None) -> tuple[str, str, str]:
|
||||
if name is not None:
|
||||
return name, INIT_DEFAULT_AUTHOR, INIT_DEFAULT_VERSION
|
||||
|
||||
plugin_name = _prompt_required_init_name()
|
||||
author = click.prompt("作者名字", default="", show_default=False).strip()
|
||||
version = click.prompt("版本", default=INIT_DEFAULT_VERSION).strip()
|
||||
return plugin_name, author, version or INIT_DEFAULT_VERSION
|
||||
|
||||
|
||||
def _render_init_plugin_yaml(
|
||||
*,
|
||||
plugin_name: str,
|
||||
display_name: str,
|
||||
author: str,
|
||||
version: str,
|
||||
python_version: str,
|
||||
) -> str:
|
||||
class_name = _class_name_for_plugin(plugin_name)
|
||||
return dedent(
|
||||
f"""\
|
||||
name: {plugin_name}
|
||||
display_name: {display_name}
|
||||
desc: 使用 AstrBot SDK 创建的插件
|
||||
author: your-name
|
||||
version: 0.1.0
|
||||
display_name: {_yaml_string(display_name)}
|
||||
desc: {_yaml_string("使用 AstrBot SDK 创建的插件")}
|
||||
author: {_yaml_string(author)}
|
||||
version: {_yaml_string(version)}
|
||||
runtime:
|
||||
python: "{python_version}"
|
||||
python: {_yaml_string(python_version)}
|
||||
components:
|
||||
- class: main:{class_name}
|
||||
"""
|
||||
@@ -394,19 +436,24 @@ def _iter_build_files(plugin_dir: Path, output_dir: Path) -> list[Path]:
|
||||
return files
|
||||
|
||||
|
||||
def _init_plugin(name: str) -> None:
|
||||
target_dir = Path(name)
|
||||
def _init_plugin(name: str | None) -> None:
|
||||
raw_name, author, version = _collect_init_inputs(name)
|
||||
normalized_name = _normalize_init_plugin_name(raw_name)
|
||||
target_dir = Path(normalized_name)
|
||||
if target_dir.exists():
|
||||
raise _CliPluginValidationError(f"目标目录已存在:{target_dir}")
|
||||
|
||||
plugin_name = _slugify_plugin_name(target_dir.name)
|
||||
display_name = target_dir.name
|
||||
plugin_name = normalized_name
|
||||
display_name = raw_name
|
||||
target_dir.mkdir(parents=True, exist_ok=False)
|
||||
(target_dir / "tests").mkdir()
|
||||
(target_dir / "plugin.yaml").write_text(
|
||||
_render_init_plugin_yaml(
|
||||
plugin_name=plugin_name,
|
||||
display_name=display_name,
|
||||
author=author,
|
||||
version=version,
|
||||
python_version=INIT_DEFAULT_PYTHON_VERSION,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -419,7 +466,7 @@ def _init_plugin(name: str) -> None:
|
||||
_render_init_test_py(plugin_name=plugin_name),
|
||||
encoding="utf-8",
|
||||
)
|
||||
click.echo(f"已创建插件骨架:{target_dir}")
|
||||
click.echo(f"已创建插件骨架:{target_dir.resolve()}")
|
||||
click.echo("后续命令:")
|
||||
click.echo(f" astrbot-sdk validate --plugin-dir {target_dir}")
|
||||
click.echo(
|
||||
@@ -485,13 +532,15 @@ def run(plugins_dir: Path) -> None:
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("name", type=str)
|
||||
def init(name: str) -> None:
|
||||
"""Create a new plugin skeleton in the target directory."""
|
||||
@click.argument("name", required=False, type=str)
|
||||
def init(name: str | None) -> None:
|
||||
"""Create a new plugin skeleton; omit name to enter interactive mode."""
|
||||
_run_sync_entrypoint(
|
||||
lambda: _init_plugin(name),
|
||||
log_message=f"创建插件骨架:{name}",
|
||||
context={"target": Path(name)},
|
||||
log_message=(
|
||||
f"创建插件骨架:{name}" if name is not None else "创建插件骨架:交互模式"
|
||||
),
|
||||
context={"target": name or "<interactive>"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -344,6 +344,7 @@ class Peer:
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
self._pending_results[request_id] = future
|
||||
# FIXME: 这里会输出乱七八糟的各种东西
|
||||
await self._send(
|
||||
InitializeMessage(
|
||||
id=request_id,
|
||||
@@ -354,6 +355,7 @@ class Peer:
|
||||
metadata=handshake_metadata,
|
||||
)
|
||||
)
|
||||
# FIXME: 👆会输出各种乱七八糟的东西
|
||||
result = await future
|
||||
if result.kind != "initialize_result":
|
||||
raise AstrBotError.protocol_error("initialize 必须收到 initialize_result")
|
||||
|
||||
@@ -537,6 +537,7 @@ class SupervisorRuntime:
|
||||
discovery = discover_plugins(self.plugins_dir)
|
||||
self.skipped_plugins = dict(discovery.skipped_plugins)
|
||||
plan_result = self.env_manager.plan(discovery.plugins)
|
||||
logger.info(f"发现 {len(discovery.plugins)} 个插件,{len(plan_result.groups)} 个环境组")
|
||||
self.skipped_plugins.update(plan_result.skipped_plugins)
|
||||
try:
|
||||
planned_sessions: list[WorkerSession] = []
|
||||
@@ -598,7 +599,7 @@ class SupervisorRuntime:
|
||||
|
||||
aggregated_handlers = list(self.handler_to_worker.keys())
|
||||
logger.info(
|
||||
"Loaded plugins: {}", ", ".join(sorted(self.loaded_plugins)) or "none"
|
||||
"Loaded plugins: \n{}", "\n ".join(sorted(self.loaded_plugins)) or "none"
|
||||
)
|
||||
|
||||
await self.peer.start()
|
||||
|
||||
@@ -304,7 +304,7 @@ class TestCliModule:
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(cli, ["init", "demo-plugin"])
|
||||
|
||||
plugin_dir = Path("demo-plugin")
|
||||
plugin_dir = Path("astrbot_plugin_demo_plugin")
|
||||
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
|
||||
main_file = (plugin_dir / "main.py").read_text(encoding="utf-8")
|
||||
test_file = (plugin_dir / "tests" / "test_plugin.py").read_text(
|
||||
@@ -313,14 +313,89 @@ class TestCliModule:
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "已创建插件骨架" in result.output
|
||||
assert "name: demo_plugin" in manifest
|
||||
assert (
|
||||
f'python: "{sys.version_info.major}.{sys.version_info.minor}"' in manifest
|
||||
)
|
||||
assert "class DemoPlugin(Star):" in main_file
|
||||
assert "name: astrbot_plugin_demo_plugin" in manifest
|
||||
assert 'display_name: "demo-plugin"' in manifest
|
||||
assert 'author: ""' in manifest
|
||||
assert 'version: "1.0.0"' in manifest
|
||||
assert 'python: "3.12"' in manifest
|
||||
assert "class AstrbotPluginDemoPlugin(Star):" in main_file
|
||||
assert "MockContext" in test_file
|
||||
assert "MockMessageEvent" in test_file
|
||||
|
||||
def test_init_command_normalizes_spaces_to_underscores(self):
|
||||
"""init should normalize spaces in the generated directory and manifest name."""
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(cli, ["init", "demo plugin"])
|
||||
|
||||
plugin_dir = Path("astrbot_plugin_demo_plugin")
|
||||
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
|
||||
assert plugin_dir.is_dir()
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "name: astrbot_plugin_demo_plugin" in manifest
|
||||
assert 'display_name: "demo plugin"' in manifest
|
||||
|
||||
def test_init_command_converts_legacy_prefix_to_underscore_prefix(self):
|
||||
"""init should translate the legacy astrbot-plugin prefix to astrbot_plugin."""
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(cli, ["init", "astrbot-plugin-demo"])
|
||||
|
||||
plugin_dir = Path("astrbot_plugin_demo")
|
||||
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
|
||||
assert plugin_dir.is_dir()
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "name: astrbot_plugin_demo" in manifest
|
||||
|
||||
def test_init_command_enters_interactive_mode_without_name(self):
|
||||
"""init without a name should prompt for plugin metadata interactively."""
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["init"],
|
||||
input="hello world\nalice\n2.3.4\n",
|
||||
)
|
||||
|
||||
plugin_dir = Path("astrbot_plugin_hello_world")
|
||||
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
|
||||
assert plugin_dir.is_dir()
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "插件名字" in result.output
|
||||
assert "作者名字" in result.output
|
||||
assert "版本" in result.output
|
||||
assert "name: astrbot_plugin_hello_world" in manifest
|
||||
assert 'display_name: "hello world"' in manifest
|
||||
assert 'author: "alice"' in manifest
|
||||
assert 'version: "2.3.4"' in manifest
|
||||
assert 'python: "3.12"' in manifest
|
||||
|
||||
def test_init_command_reprompts_for_empty_interactive_name(self):
|
||||
"""init interactive mode should reject an empty plugin name and keep prompting."""
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["init"],
|
||||
input="\nhello world\n\n\n",
|
||||
)
|
||||
|
||||
plugin_dir = Path("astrbot_plugin_hello_world")
|
||||
manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8")
|
||||
assert plugin_dir.is_dir()
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "插件名字不能为空" in result.output
|
||||
assert 'author: ""' in manifest
|
||||
assert 'version: "1.0.0"' in manifest
|
||||
|
||||
def test_validate_command_checks_real_plugin_fixture(self):
|
||||
"""validate should reuse loader-based discovery against a real v4 fixture."""
|
||||
runner = CliRunner()
|
||||
@@ -379,11 +454,11 @@ class TestCliModule:
|
||||
[
|
||||
"build",
|
||||
"--plugin-dir",
|
||||
"buildable-plugin",
|
||||
"astrbot_plugin_buildable_plugin",
|
||||
],
|
||||
)
|
||||
|
||||
artifact_dir = Path("buildable-plugin") / "dist"
|
||||
artifact_dir = Path("astrbot_plugin_buildable_plugin") / "dist"
|
||||
artifacts = sorted(artifact_dir.glob("*.zip"))
|
||||
assert len(artifacts) == 1
|
||||
with zipfile.ZipFile(artifacts[0]) as archive:
|
||||
|
||||
Reference in New Issue
Block a user