Files
dify-plugin-sdks/python/dify_plugin/integration/run.py
T
Yeuoly 46eb504125 feat[0.3.0]: add plugin runner functionality (#144)
* feat: add integration configuration and plugin runner functionality

- Introduced `IntegrationConfig` class for managing plugin testing configurations, including validation for the `dify_cli_path`.
- Added `PluginInvokeRequest` and `PluginGenericResponse` models to handle plugin invocation requests and responses.
- Implemented `PluginRunner` class to facilitate running plugins locally, including asynchronous message handling and process management.
- Merged access actions into a single `PluginAccessAction` for streamlined access control.

* apply ruff

* feat: enhance plugin runner with new response handling and exception management

- Added `PLUGIN_INVOKE_END` response type to manage the end of plugin invocations.
- Introduced `PluginStopped` exception to handle plugin termination gracefully.
- Updated `PluginRunner` to support stopping the plugin process and handling asynchronous message reading with improved error management.
- Modified message queue to accommodate `None` responses for completed invocations.

* refactor: rename PluginStopped exception to PluginStoppedError

- Renamed `PluginStopped` to `PluginStoppedError` for clarity and consistency.
- Updated references in the `run.py` file to reflect the new exception name.
- Added a docstring to `PluginStoppedError` to describe its purpose.

* fix: update version check for dify CLI in IntegrationConfig

- Modified the version check in the IntegrationConfig class to require a minimum version of 0.1.0 instead of 0.4.0.
- Ensured that the version validation logic is clearer and more robust.

* chore: update dependencies and lock file

- Updated the `content_hash` in `pdm.lock` to reflect changes in dependencies.
- Added `packaging>=25.0` to the dependencies in `pyproject.toml` to ensure compatibility with the latest packaging features.

* feat: enhance IntegrationConfig with improved CLI path validation

- Added a list of potential plugin names to search for the dify CLI executable.
- Updated the validation logic to check for the CLI path against multiple plugin names.
- Changed error handling to use ValidationError for invalid CLI path and version checks.

* feat: add setup script for dify-plugin-cli installation

- Introduced a new script to automate the installation of dify-plugin-cli, including OS and architecture detection.
- Updated the pull request workflow to include the setup of the dify-plugin-cli before installing dependencies.
- Refactored error handling in IntegrationConfig to use ValueError instead of ValidationError for CLI path and version checks.

* feat: add integration test for invoking LLM with mocked OpenAI server

- Introduced a new integration test in `test_invoke_llm.py` to validate the invocation of the LLM plugin using a mocked OpenAI server.
- Implemented a mock server to simulate OpenAI's chat completions API, supporting both streaming and non-streaming responses.
- The test downloads the latest `langgenius-openai` plugin, runs the plugin, and asserts the expected response from the LLM.

* fix: add timeout to requests in LLM integration test

- Updated the `test_invoke_llm.py` file to include a timeout of 10 seconds for both POST and GET requests to the marketplace API.
- This change aims to improve the reliability of the integration test by preventing indefinite blocking on network calls.

* feat: enhance dify-plugin-cli setup script with installation verification and PATH configuration

- Added verification to check if dify-plugin is available in the user's PATH after installation.
- Implemented logic to add dify-plugin to the system PATH for all users when run as root, or to the user's profile for non-root users.
- Created symlinks in common bin directories and provided usage instructions for the dify-plugin commands.

* refactor: update dify-plugin-cli setup script for improved PATH management

- Enhanced the script to add the installation directory to the GITHUB_PATH for GitHub Actions.
- Modified the PATH export for local development to ensure the installation directory is prioritized.
- Removed redundant installation verification and symlink creation logic, streamlining the setup process.

* test: add SSH support to pull request workflow

- Integrated SSH functionality into the pull request workflow using the mxschmitt/action-tmate@v3 action.
- This addition allows for interactive debugging and terminal access during pull request checks.

* chore: remove SSH step from pull request workflow and fix import in integration test

- Eliminated the SSH step from the pull request workflow to streamline the process.
- Fixed the import statement for the `requests` library in `test_invoke_llm.py` to ensure proper functionality during the test execution.

* wtf

* feat: implement OpenAI mock server for integration testing

- Added a new mock server in `python/tests/__mock_server` to simulate OpenAI's chat completions API, supporting both streaming and non-streaming responses.
- Updated the pull request workflow to install the `uv` tool and launch the mock server during testing.
- Refactored `test_invoke_llm.py` to utilize the mock server, ensuring reliable integration tests without external dependencies.

* apply ruff

* refactor: streamline mock server launch in pull request workflow

- Replaced inline mock server launch commands in the pull request workflow with a dedicated script `launch_mock_server.sh` for improved readability and maintainability.
- The new script handles the execution of the mock server, ensuring a cleaner workflow configuration.

* fix: update mock server launch path in pull request workflow

- Changed the path for launching the mock server script from `./scripts/launch_mock_server.sh` to `./python/scripts/launch_mock_server.sh` to ensure correct execution within the workflow.

* fix: run mock server in background during pull request workflow

- Updated the pull request workflow to launch the mock server script in the background by appending `&` to the command. This change allows subsequent steps to execute without waiting for the mock server to terminate.

* chore: clean up dify-plugin-cli setup script by removing outdated installation instructions

- Removed outdated comments regarding the download and installation of dify-plugin-cli, streamlining the script for clarity.

* feat: enhance PluginRunner with graceful shutdown and thread safety

- Introduced a stop flag and a lock to ensure thread-safe access to the stop flag in the PluginRunner class.
- Implemented a _close method to handle graceful termination of the plugin process, including sending a SIGTERM signal and closing pipes.
- Refactored the _read_async method to ensure proper cleanup and handling of messages, maintaining the integrity of the message queue.
2025-05-19 14:56:13 +08:00

243 lines
7.7 KiB
Python

import logging
import os
import signal
import subprocess
import threading
import uuid
from collections.abc import Generator
from queue import Queue
from threading import Lock, Semaphore
from typing import TypeVar
from gevent.os import tp_read
from pydantic import BaseModel, ValidationError
from dify_plugin.config.integration_config import IntegrationConfig
from dify_plugin.core.entities.plugin.request import (
PluginAccessAction,
PluginInvokeType,
)
from dify_plugin.integration.entities import PluginGenericResponse, PluginInvokeRequest, ResponseType
from dify_plugin.integration.exc import PluginStoppedError
T = TypeVar("T")
logger = logging.getLogger(__name__)
class PluginRunner:
"""
A class that runs a plugin locally.
Usage:
```python
with PluginRunner(
config=IntegrationConfig(),
plugin_package_path="./langgenius-agent_0.0.14.difypkg",
) as runner:
for result in runner.invoke(
PluginInvokeType.Agent,
AgentActions.InvokeAgentStrategy,
payload=request.AgentInvokeRequest(
user_id="hello",
agent_strategy_provider="agent",
agent_strategy="function_calling",
agent_strategy_params=agent_strategy_params,
),
response_type=AgentInvokeMessage,
):
assert result
```
"""
R = TypeVar("R", bound=BaseModel)
def __init__(self, config: IntegrationConfig, plugin_package_path: str, extra_args: list[str] | None = None):
self.config = config
self.plugin_package_path = plugin_package_path
self.extra_args = extra_args or []
# create pipe to communicate with the plugin
self.stdout_pipe_read, self.stdout_pipe_write = os.pipe()
self.stderr_pipe_read, self.stderr_pipe_write = os.pipe()
self.stdin_pipe_read, self.stdin_pipe_write = os.pipe()
# stdin write lock
self.stdin_write_lock = Lock()
# setup stop flag
self.stop_flag = False
self.stop_flag_lock = Lock()
logger.info(f"Running plugin from {plugin_package_path}")
self.process = subprocess.Popen( # noqa: S603
[config.dify_cli_path, "plugin", "run", plugin_package_path, "--response-format", "json", *self.extra_args],
stdout=self.stdout_pipe_write,
stderr=self.stderr_pipe_write,
stdin=self.stdin_pipe_read,
)
logger.info(f"Plugin process created with pid {self.process.pid}")
# wait for plugin to be ready
self.ready_semaphore = Semaphore(0)
# create a thread to read the stdout and stderr
self.stdout_reader = threading.Thread(target=self._message_reader, args=(self.stdout_pipe_read,))
try:
self.stdout_reader.start()
except Exception as e:
raise e
self.q = dict[str, Queue[PluginGenericResponse | None]]()
self.q_lock = Lock()
# wait for the plugin to be ready
self.ready_semaphore.acquire()
logger.info("Plugin ready")
def _close(self):
with self.stop_flag_lock:
if self.stop_flag:
return
# stop the plugin
self.stop_flag = True
# send signal SIGTERM to the plugin, so it can exit gracefully
# do collect garbage like removing temporary files
os.kill(self.process.pid, signal.SIGTERM)
# wait for the plugin to exit
self.process.wait()
# close the pipes
os.close(self.stdout_pipe_write)
os.close(self.stderr_pipe_write)
os.close(self.stdin_pipe_read)
def _read_async(self, fd: int) -> bytes:
# read data from stdin using tp_read in 64KB chunks.
# the OS buffer for stdin is usually 64KB, so using a larger value doesn't make sense.
b = tp_read(fd, 65536)
if not b:
raise PluginStoppedError()
return b
def _message_reader(self, pipe: int):
# create a scanner to read the message line by line
"""Read messages line by line from the pipe."""
buffer = b""
try:
while True:
try:
data = self._read_async(pipe)
except PluginStoppedError:
break
if not data:
continue
buffer += data
# if no b"\n" is in data, skip to the next iteration
if data.find(b"\n") == -1:
continue
# process line by line and keep the last line if it is not complete
lines = buffer.split(b"\n")
buffer = lines[-1]
lines = lines[:-1]
for line in lines:
line = line.strip()
if not line:
continue
self._publish_message(line.decode("utf-8"))
finally:
self._close()
def _publish_message(self, message: str):
# parse the message
try:
parsed_message = PluginGenericResponse.model_validate_json(message)
except ValidationError:
return
if not parsed_message.invoke_id:
if parsed_message.type == ResponseType.PLUGIN_READY:
self.ready_semaphore.release()
elif parsed_message.type == ResponseType.ERROR:
raise ValueError(parsed_message.response)
elif parsed_message.type == ResponseType.INFO:
logger.info(parsed_message.response)
return
with self.q_lock:
if parsed_message.invoke_id not in self.q:
return
if parsed_message.type == ResponseType.PLUGIN_INVOKE_END:
self.q[parsed_message.invoke_id].put(None)
else:
self.q[parsed_message.invoke_id].put(parsed_message)
def _write_to_pipe(self, data: bytes):
# split the data into chunks of 4096 bytes
chunks = [data[i : i + 4096] for i in range(0, len(data), 4096)]
with (
self.stdin_write_lock
): # a lock is needed to avoid race condition when facing multiple threads writing to the pipe.
for chunk in chunks:
os.write(self.stdin_pipe_write, chunk)
def invoke(
self,
access_type: PluginInvokeType,
access_action: PluginAccessAction,
payload: BaseModel,
response_type: type[R],
) -> Generator[R, None, None]:
with self.stop_flag_lock:
if self.stop_flag:
raise PluginStoppedError()
invoke_id = uuid.uuid4().hex
request = PluginInvokeRequest(
invoke_id=invoke_id,
type=access_type,
action=access_action,
request=payload,
)
q = Queue[PluginGenericResponse | None]()
with self.q_lock:
self.q[invoke_id] = q
try:
# send invoke request to the plugin
self._write_to_pipe(request.model_dump_json().encode("utf-8") + b"\n")
# wait for events
while message := q.get():
if message.invoke_id == invoke_id:
if message.type == ResponseType.PLUGIN_RESPONSE:
yield response_type.model_validate(message.response)
elif message.type == ResponseType.ERROR:
raise ValueError(message.response)
else:
raise ValueError("Invalid response type")
else:
raise ValueError("Invalid invoke id")
finally:
with self.q_lock:
del self.q[invoke_id]
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self._close()