Skip to content
Merged
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
46 changes: 46 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Tests

on:
push:
branches:
- main
- test-build
pull_request:

permissions:
contents: read

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Python
run: uv python install ${{ matrix.python-version }}
- name: Run tests with coverage
run: |
uv run --python ${{ matrix.python-version }} pytest \
--cov=langbot_plugin \
--cov-report=term-missing \
--cov-fail-under=75

test-lint:
name: Test Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Ruff tests
run: uv run ruff check tests --output-format=concise
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ dependencies = [
[dependency-groups]
dev = [
"mypy>=1.16.0",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
"ruff>=0.11.12",
]

Expand All @@ -44,3 +46,17 @@ Issues = "https://github.com/langbot-app/langbot-plugin-sdk/issues"

[tool.setuptools]
package-data = { "langbot_plugin" = ["assets/templates/*", "assets/*.js"] }

[tool.pytest.ini_options]
addopts = "-ra"
testpaths = ["tests"]
asyncio_mode = "auto"
pythonpath = ["."]

[tool.coverage.run]
branch = true
source = ["langbot_plugin"]

[tool.coverage.report]
show_missing = true
skip_covered = true
5 changes: 2 additions & 3 deletions src/langbot_plugin/api/entities/builtin/command/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,17 @@ class CommandReturn(pydantic.BaseModel):
"""错误,保留供系统使用,插件逻辑报错请自行使用 text 传递
"""

@classmethod
@pydantic.field_validator("error", mode="before")
@classmethod
def _validate_error(
cls, v: Optional[errors.CommandError]
) -> Optional[errors.CommandError]:
if v is not None:
return errors.CommandError(message=v.message)
return v

@classmethod
@pydantic.field_serializer("error")
def _serialize_error(cls, v: Optional[errors.CommandError]) -> Optional[str]:
def _serialize_error(self, v: Optional[errors.CommandError]) -> Optional[str]:
if v is not None:
return v.message
return v
Expand Down
8 changes: 4 additions & 4 deletions src/langbot_plugin/api/entities/builtin/command/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@ def __str__(self):


class CommandNotFoundError(CommandError):
def __init__(self, message: str = None):
def __init__(self, message: str = ""):
super().__init__("未知命令: " + message)


class CommandPrivilegeError(CommandError):
def __init__(self, message: str = None):
def __init__(self, message: str = ""):
super().__init__("权限不足: " + message)


class ParamNotEnoughError(CommandError):
def __init__(self, message: str = None):
def __init__(self, message: str = ""):
super().__init__("参数不足: " + message)


class CommandOperationError(CommandError):
def __init__(self, message: str = None):
def __init__(self, message: str = ""):
super().__init__("操作失败: " + message)
5 changes: 4 additions & 1 deletion src/langbot_plugin/api/entities/builtin/provider/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ def get_content_platform_message_chain(
platform_message.File(url=ce.file_url, name=ce.file_name)
)
elif ce.type == "image_url":
assert ce.image_url is not None
if ce.image_url is None:
raise ValueError("image_url content requires image_url payload")
if ce.image_url.url.startswith("http"):
mc.append(platform_message.Image(url=ce.image_url.url))
# else: # base64, for backward compatibility
Expand Down Expand Up @@ -223,6 +224,8 @@ def get_content_platform_message_chain(
platform_message.File(url=ce.file_url, name=ce.file_name)
)
elif ce.type == "image_url":
if ce.image_url is None:
raise ValueError("image_url content requires image_url payload")
if ce.image_url.url.startswith("http"):
mc.append(platform_message.Image(url=ce.image_url.url))
# else: # base64
Expand Down
6 changes: 4 additions & 2 deletions src/langbot_plugin/runtime/io/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,9 @@ def __init__(
@self.action(CommonAction.FILE_CHUNK)
async def file_chunk(data: dict[str, Any]) -> ActionResponse:
file_key = data["file_key"]
file_length = data["file_length"]
chunk_base64 = data["chunk_base64"]
chunk_index = data["chunk_index"]
chunk_amount = data["chunk_amount"]
chunk_size = data["chunk_size"]
# append the chunk to the file
async with aiofiles.open(
os.path.join(FILE_STORAGE_DIR, file_key), "ab"
Expand Down Expand Up @@ -180,6 +178,8 @@ async def call_action(
return response.data
except asyncio.TimeoutError:
raise ActionCallTimeoutError(f"Action {action.value} call timed out")
except ActionCallError:
raise
except Exception as e:
raise ActionCallError(f"{e.__class__.__name__}: {str(e)}")
finally:
Expand Down Expand Up @@ -218,6 +218,8 @@ async def call_action_generator(
raise ActionCallTimeoutError(
f"Action {action.value} call timed out"
)
except ActionCallError:
raise
except Exception as e:
raise ActionCallError(f"{e.__class__.__name__}: {str(e)}")
finally:
Expand Down
7 changes: 3 additions & 4 deletions src/langbot_plugin/runtime/plugin/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from langbot_plugin.api.definition.plugin import BasePlugin
from langbot_plugin.api.definition.components.base import BaseComponent, NoneComponent
from langbot_plugin.api.definition.components.manifest import ComponentManifest
from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler


class RuntimeContainerStatus(enum.Enum):
Expand Down Expand Up @@ -59,9 +58,7 @@ class PluginContainer(pydantic.BaseModel):
components: list[ComponentContainer]
"""组件容器列表"""

_runtime_plugin_handler: PluginConnectionHandler | None = pydantic.PrivateAttr(
default=None
)
_runtime_plugin_handler: typing.Any = pydantic.PrivateAttr(default=None)

class Config:
arbitrary_types_allowed = True
Expand All @@ -84,6 +81,8 @@ def model_dump(self):
def from_dict(cls, data: dict[str, typing.Any]) -> PluginContainer:
return cls(
debug=data["debug"],
install_source=data.get("install_source", ""),
install_info=data.get("install_info", {}),
manifest=ComponentManifest.model_validate(data["manifest"]),
plugin_instance=NonePlugin(),
enabled=data["enabled"],
Expand Down
4 changes: 2 additions & 2 deletions src/langbot_plugin/runtime/plugin/mgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ class PluginManager:

def __init__(self, context: context_module.RuntimeContext):
self.context = context
self.plugin_handlers = []
self.plugins = []
self.plugin_run_tasks = []
self.wait_for_control_connection = None

Expand Down Expand Up @@ -621,8 +623,6 @@ async def emit_event(
if resp["emitted"]:
emitted_plugins.append(plugin)

emitted_plugins.append(plugin)

event_context = EventContext.model_validate(resp["event_context"])

if event_context.is_prevented_postorder():
Expand Down
3 changes: 3 additions & 0 deletions src/langbot_plugin/utils/discover/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class ComponentDiscoveryEngine:
components: typing.Dict[str, typing.List[ComponentManifest]] = {}
"""组件列表"""

def __init__(self):
self.components = {}

def load_component_manifest(
self, path: str, owner: str = "builtin", no_save: bool = False
) -> ComponentManifest | None:
Expand Down
36 changes: 25 additions & 11 deletions src/langbot_plugin/utils/importutil.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import importlib
import importlib.util
import os
import pkgutil
import sys
import typing


Expand All @@ -10,6 +12,12 @@ def import_modules_in_pkg(pkg: typing.Any) -> None:
Args:
pkg: 要导入的包对象
"""
if hasattr(pkg, "__path__"):
for module_info in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
if not module_info.ispkg:
importlib.import_module(module_info.name)
return

pkg_path = os.path.dirname(pkg.__file__)
import_dir(pkg_path)

Expand All @@ -20,19 +28,25 @@ def import_modules_in_pkgs(pkgs: typing.List) -> None:


def import_dot_style_dir(dot_sep_path: str):
sec = dot_sep_path.split(".")

return import_dir(os.path.join(*sec))
pkg = importlib.import_module(dot_sep_path)
return import_modules_in_pkg(pkg)


def import_dir(path: str):
abs_path = os.path.abspath(path)
for file in os.listdir(path):
if file.endswith(".py") and file != "__init__.py":
full_path = os.path.join(path, file)
rel_path = full_path.replace(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ""
)
rel_path = rel_path[1:]
rel_path = rel_path.replace("/", ".")[:-3]
rel_path = rel_path.replace("\\", ".")
importlib.import_module(rel_path)
full_path = os.path.abspath(os.path.join(abs_path, file))
module_path = full_path[:-3]
for root in sorted(sys.path, key=len, reverse=True):
if not root:
root = os.getcwd()
abs_root = os.path.abspath(root)
try:
rel_path = os.path.relpath(module_path, abs_root)
except ValueError:
continue
if rel_path.startswith(".."):
continue
importlib.import_module(rel_path.replace(os.sep, "."))
break
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

97 changes: 97 additions & 0 deletions tests/api/definition/components/test_components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

import pytest

from langbot_plugin.api.definition.components.base import BaseComponent, NoneComponent
from langbot_plugin.api.definition.components.command.command import Command
from langbot_plugin.api.definition.components.common.event_listener import EventListener
from langbot_plugin.api.definition.components.knowledge_engine.engine import (
KnowledgeEngine,
KnowledgeEngineCapability,
)
from langbot_plugin.api.definition.components.page import Page, PageRequest, PageResponse
from langbot_plugin.api.definition.components.parser.parser import Parser
from langbot_plugin.api.definition.components.tool.tool import Tool
from langbot_plugin.api.entities.builtin.command.context import CommandReturn
from langbot_plugin.api.entities.events import PersonMessageReceived


def test_base_and_none_components_initialize_as_noop():
component = NoneComponent()
assert isinstance(component, BaseComponent)


def test_event_listener_registers_multiple_handlers_for_event_type():
listener = EventListener()

async def first(_ctx):
return None

async def second(_ctx):
return None

assert listener.handler(PersonMessageReceived)(first) is first
listener.handler(PersonMessageReceived)(second)

assert listener.registered_handlers[PersonMessageReceived] == [first, second]


def test_command_subcommand_decorator_records_metadata():
command = Command()

async def handler(_ctx):
yield CommandReturn(text="ok")

assert command.subcommand("run", help="Run", usage="/run", aliases=["r"])(
handler
) is handler

registered = command.registered_subcommands["run"]
assert registered.help == "Run"
assert registered.usage == "/run"
assert registered.aliases == ["r"]


def test_command_subcommand_default_aliases_should_not_be_shared():
first = Command()
second = Command()

async def first_handler(_ctx):
yield CommandReturn(text="first")

async def second_handler(_ctx):
yield CommandReturn(text="second")

first.subcommand("first")(first_handler)
second.subcommand("second")(second_handler)
first.registered_subcommands["first"].aliases.append("alias")

assert second.registered_subcommands["second"].aliases == []


def test_page_request_response_helpers_and_default_handler():
request = PageRequest(endpoint="/entries", method="GET", headers={"x": "1"})
assert request.body is None
assert request.headers == {"x": "1"}
assert PageResponse.ok({"ok": True}).data == {"ok": True}
assert PageResponse.fail("nope").error == "nope"


@pytest.mark.asyncio
async def test_page_default_handle_api_returns_not_implemented_failure():
response = await Page().handle_api(PageRequest(endpoint="/", method="GET"))

assert response.error == "Not implemented"


def test_knowledge_engine_default_capabilities():
assert KnowledgeEngine.get_capabilities() == [KnowledgeEngineCapability.DOC_INGESTION]


def test_abstract_component_kinds_are_stable():
assert Command.__kind__ == "Command"
assert EventListener.__kind__ == "EventListener"
assert KnowledgeEngine.__kind__ == "KnowledgeEngine"
assert Parser.__kind__ == "Parser"
assert Tool.__kind__ == "Tool"
assert Page.__kind__ == "Page"
Loading
Loading