mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
Merge pull request #12 from united-pooh/feat/cli-create
feat(cli): normalize plugin init skeletons
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