Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
from .filter.permission import COMMAND_PERMISSION_TYPES, PermissionTypeFilter
from .star import star_map, star_registry
from .star_handler import EventType, star_handlers_registry
from .updater import PLUGIN_METADATA_FILENAMES, _PluginUpdater
from .updater import PLUGIN_METADATA_FILENAMES, _PluginUpdater, load_plugin_yaml

try:
from watchfiles import PythonFilter, awatch
Expand Down Expand Up @@ -517,7 +517,7 @@ def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | N
if metadata_path:
metadata_label = metadata_path.name
with metadata_path.open(encoding="utf-8") as f:
metadata = yaml.safe_load(f)
metadata = load_plugin_yaml(f)

if isinstance(metadata, dict):
if "desc" not in metadata and "description" in metadata:
Expand Down Expand Up @@ -652,7 +652,7 @@ def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str:
)

with metadata_path.open(encoding="utf-8") as f:
metadata = yaml.safe_load(f)
metadata = load_plugin_yaml(f)

if not isinstance(metadata, dict):
raise Exception(f"{metadata_path.name} 格式错误。")
Expand Down
45 changes: 42 additions & 3 deletions astrbot/core/star/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@
__all__ = ["PLUGIN_METADATA_FILENAMES"]


class _StringScalarSafeLoader(yaml.SafeLoader):
"""SafeLoader that keeps every scalar as its original source text.

Prevents lossy YAML numeric coercion such as `version: 2.10` being
parsed as the float 2.1 and later stringified as "2.1".
"""


# Clear inherited implicit resolvers so plain scalars (e.g. 2.10, true, null)
# are not re-typed by YAML and instead keep their original string form.
_StringScalarSafeLoader.yaml_implicit_resolvers = {}

_StringScalarSafeLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG,
lambda loader, node: loader.construct_scalar(node),
)


def load_plugin_yaml(stream):
"""Parse plugin YAML while preserving each scalar's original text.

Args:
stream: YAML source as str, bytes, or a file object.

Returns:
Parsed Python object; scalar values keep their source representation.
"""
return yaml.load(stream, Loader=_StringScalarSafeLoader)


class _PluginUpdater(_RepoZipUpdater):
"""Install and update plugins from repository source archives."""

Expand Down Expand Up @@ -174,7 +204,7 @@ async def inspect_repository(
except UnicodeDecodeError as exc:
raise ValueError(f"{filename} 必须使用 UTF-8 编码。") from exc
try:
metadata = yaml.safe_load(metadata_text)
metadata = load_plugin_yaml(metadata_text)
except yaml.YAMLError as exc:
raise ValueError(f"{filename} 格式错误。") from exc
try:
Expand Down Expand Up @@ -371,6 +401,15 @@ def validate_plugin_metadata(metadata: object, metadata_label: str) -> None:
if "desc" not in normalized_metadata and "description" in normalized_metadata:
normalized_metadata["desc"] = normalized_metadata["description"]

# YAML parses unquoted versions like `version: 2.4` as floats. Coerce
# scalar numbers to strings in the caller's dict as well, so callers
# reading the original metadata (e.g. StarMetadata loading) see a str.
for field in PLUGIN_METADATA_REQUIRED_FIELDS:
value = normalized_metadata.get(field)
if isinstance(value, (int, float)) and not isinstance(value, bool):
normalized_metadata[field] = str(value)
metadata[field] = str(value)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

missing_fields = [
field
for field in PLUGIN_METADATA_REQUIRED_FIELDS
Expand Down Expand Up @@ -413,7 +452,7 @@ def inspect_plugin_directory(cls, plugin_path: str | Path) -> dict[str, object]:
if metadata_path.stat().st_size > PLUGIN_METADATA_MAX_BYTES:
raise ValueError(f"{filename} 超过 1MB。")
try:
metadata = yaml.safe_load(metadata_path.read_text(encoding="utf-8"))
metadata = load_plugin_yaml(metadata_path.read_text(encoding="utf-8"))
except UnicodeDecodeError as exc:
raise ValueError(f"{filename} 必须使用 UTF-8 编码。") from exc
except yaml.YAMLError as exc:
Expand Down Expand Up @@ -445,7 +484,7 @@ def inspect_plugin_archive(cls, zip_path: str) -> dict[str, object]:

try:
metadata_text = z.read(metadata_entry).decode("utf-8")
metadata = yaml.safe_load(metadata_text)
metadata = load_plugin_yaml(metadata_text)
except UnicodeDecodeError as exc:
raise ValueError(f"{metadata_entry} 必须使用 UTF-8 编码。") from exc
except yaml.YAMLError as exc:
Expand Down
52 changes: 52 additions & 0 deletions tests/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from astrbot.core.star import star_manager as star_manager_module
from astrbot.core.star.star_handler import EventType, StarHandlerMetadata
from astrbot.core.star.star_manager import PluginDependencyInstallError, PluginManager
from astrbot.core.star.updater import _PluginUpdater
from astrbot.core.utils.pip_installer import PipInstallError
from astrbot.core.utils.requirements_utils import MissingRequirementsPlan

Expand Down Expand Up @@ -218,6 +219,57 @@ def test_load_plugin_metadata_preserves_validation_error(
PluginManager._load_plugin_metadata(str(plugin_path))


def test_validate_plugin_metadata_coerces_numeric_version_to_string() -> None:
"""YAML parses unquoted `version: 2.4` as a float; it must be coerced to str."""
metadata = {
"name": TEST_PLUGIN_NAME,
"desc": "test plugin",
"version": 2.4,
"author": "AstrBot Team",
}

_PluginUpdater.validate_plugin_metadata(metadata, "metadata.yaml")

assert metadata["version"] == "2.4"
assert isinstance(metadata["version"], str)


def test_load_plugin_metadata_coerces_numeric_version_to_string(tmp_path: Path) -> None:
"""A plugin whose metadata.yaml uses an unquoted numeric version must load."""
plugin_path = tmp_path / "helloworld"
plugin_path.mkdir()
(plugin_path / "metadata.yaml").write_text(
"name: helloworld\n"
"desc: test plugin\n"
"version: 2.4\n"
"author: AstrBot Team\n",
encoding="utf-8",
)

loaded_metadata = PluginManager._load_plugin_metadata(str(plugin_path))

assert loaded_metadata is not None
assert loaded_metadata.version == "2.4"


def test_load_plugin_metadata_preserves_trailing_zero_version(tmp_path: Path) -> None:
"""Unquoted `version: 2.10` must keep its original text, not become "2.1"."""
plugin_path = tmp_path / "helloworld"
plugin_path.mkdir()
(plugin_path / "metadata.yaml").write_text(
"name: helloworld\n"
"desc: test plugin\n"
"version: 2.10\n"
"author: AstrBot Team\n",
encoding="utf-8",
)

loaded_metadata = PluginManager._load_plugin_metadata(str(plugin_path))

assert loaded_metadata is not None
assert loaded_metadata.version == "2.10"


def test_loaded_metadata_can_copy_i18n_into_existing_star_metadata(tmp_path: Path):
plugin_path = tmp_path / "helloworld"
_write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO)
Expand Down