2025-12-31 11:14:41 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Script to extract plugin version information from Python files.
|
|
|
|
|
用于从 Python 插件文件中提取版本信息的脚本。
|
|
|
|
|
|
|
|
|
|
This script scans the plugins directory and extracts metadata (title, version, author, description)
|
|
|
|
|
from Python files that follow the OpenWebUI plugin docstring format.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
python extract_plugin_versions.py # Output to console
|
|
|
|
|
python extract_plugin_versions.py --json # Output as JSON
|
|
|
|
|
python extract_plugin_versions.py --markdown # Output as Markdown table
|
|
|
|
|
python extract_plugin_versions.py --compare old.json # Compare with previous version file
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_plugin_metadata(file_path: str) -> dict[str, Any] | None:
|
|
|
|
|
"""
|
|
|
|
|
Extract plugin metadata from a Python file's docstring.
|
|
|
|
|
从 Python 文件的文档字符串中提取插件元数据。
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
file_path: Path to the Python file
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary containing plugin metadata or None if not a valid plugin file
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
|
|
|
content = f.read()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error reading {file_path}: {e}", file=sys.stderr)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Match the docstring at the beginning of the file (allowing leading whitespace/comments)
|
|
|
|
|
docstring_pattern = r'^\s*"""(.*?)"""'
|
|
|
|
|
match = re.search(docstring_pattern, content, re.DOTALL)
|
|
|
|
|
|
|
|
|
|
if not match:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
docstring = match.group(1)
|
|
|
|
|
|
|
|
|
|
# Extract metadata fields
|
|
|
|
|
metadata = {}
|
|
|
|
|
field_patterns = {
|
|
|
|
|
"title": r"title:\s*(.+?)(?:\n|$)",
|
|
|
|
|
"author": r"author:\s*(.+?)(?:\n|$)",
|
|
|
|
|
"author_url": r"author_url:\s*(.+?)(?:\n|$)",
|
|
|
|
|
"funding_url": r"funding_url:\s*(.+?)(?:\n|$)",
|
|
|
|
|
"version": r"version:\s*(.+?)(?:\n|$)",
|
|
|
|
|
"description": r"description:\s*(.+?)(?:\n|$)",
|
|
|
|
|
"requirements": r"requirements:\s*(.+?)(?:\n|$)",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for field, pattern in field_patterns.items():
|
|
|
|
|
field_match = re.search(pattern, docstring, re.IGNORECASE)
|
|
|
|
|
if field_match:
|
|
|
|
|
metadata[field] = field_match.group(1).strip()
|
|
|
|
|
|
|
|
|
|
# Only return if we found at least title and version
|
|
|
|
|
if "title" in metadata and "version" in metadata:
|
|
|
|
|
metadata["file_path"] = file_path
|
|
|
|
|
return metadata
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def scan_plugins_directory(plugins_dir: str) -> list[dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Scan the plugins directory and extract metadata from all plugin files.
|
|
|
|
|
扫描 plugins 目录并从所有插件文件中提取元数据。
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
plugins_dir: Path to the plugins directory
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of plugin metadata dictionaries
|
|
|
|
|
"""
|
|
|
|
|
plugins = []
|
|
|
|
|
plugins_path = Path(plugins_dir)
|
|
|
|
|
|
|
|
|
|
if not plugins_path.exists():
|
|
|
|
|
print(f"Plugins directory not found: {plugins_dir}", file=sys.stderr)
|
|
|
|
|
return plugins
|
|
|
|
|
|
|
|
|
|
# Walk through all subdirectories
|
2026-01-28 02:30:38 +08:00
|
|
|
for root, dirs, files in os.walk(plugins_path):
|
|
|
|
|
# Exclude debug directory from scan
|
|
|
|
|
if "debug" in dirs:
|
|
|
|
|
dirs.remove("debug")
|
|
|
|
|
|
2025-12-31 11:14:41 +00:00
|
|
|
for file in files:
|
|
|
|
|
if file.endswith(".py") and not file.startswith("__"):
|
2026-01-03 11:00:14 +08:00
|
|
|
# Skip specific files that should not trigger release
|
2026-01-03 11:54:17 +08:00
|
|
|
if file in [
|
|
|
|
|
"gemini_manifold.py",
|
|
|
|
|
"gemini_manifold_companion.py",
|
|
|
|
|
"ACTION_PLUGIN_TEMPLATE.py",
|
|
|
|
|
"ACTION_PLUGIN_TEMPLATE_CN.py",
|
|
|
|
|
]:
|
2026-01-03 11:00:14 +08:00
|
|
|
continue
|
|
|
|
|
|
2025-12-31 11:14:41 +00:00
|
|
|
file_path = os.path.join(root, file)
|
2026-01-28 11:11:38 +08:00
|
|
|
metadata = extract_plugin_metadata(file_path)
|
2025-12-31 11:14:41 +00:00
|
|
|
if metadata:
|
|
|
|
|
# Determine plugin type from directory structure
|
|
|
|
|
rel_path = os.path.relpath(file_path, plugins_dir)
|
2026-01-28 02:28:07 +08:00
|
|
|
|
|
|
|
|
# Normalize file_path to always start with "plugins/" for consistent ID comparison
|
|
|
|
|
# regardless of where we scan from (/tmp/old_repo or ./plugins)
|
|
|
|
|
metadata["file_path"] = os.path.join("plugins", rel_path)
|
|
|
|
|
|
2025-12-31 11:14:41 +00:00
|
|
|
parts = rel_path.split(os.sep)
|
|
|
|
|
if len(parts) > 0:
|
|
|
|
|
metadata["type"] = parts[0] # actions, filters, pipes, etc.
|
|
|
|
|
plugins.append(metadata)
|
|
|
|
|
|
|
|
|
|
return plugins
|
|
|
|
|
|
|
|
|
|
|
2026-01-03 10:55:05 +08:00
|
|
|
def compare_versions(current: list[dict], previous_file: str) -> dict[str, list[dict]]:
|
2025-12-31 11:14:41 +00:00
|
|
|
"""
|
|
|
|
|
Compare current plugin versions with a previous version file.
|
|
|
|
|
比较当前插件版本与之前的版本文件。
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
current: List of current plugin metadata
|
|
|
|
|
previous_file: Path to JSON file with previous versions
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary with 'added', 'updated', 'removed' lists
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
with open(previous_file, "r", encoding="utf-8") as f:
|
|
|
|
|
previous = json.load(f)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return {"added": current, "updated": [], "removed": []}
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
print(f"Error parsing {previous_file}", file=sys.stderr)
|
|
|
|
|
return {"added": current, "updated": [], "removed": []}
|
|
|
|
|
|
2026-01-28 02:14:30 +08:00
|
|
|
# Create lookup dictionaries by file_path (fallback to title)
|
|
|
|
|
# Helper to extract title/version/file_path from either simple dict or raw post object
|
2026-01-08 00:14:32 +08:00
|
|
|
def get_info(p):
|
|
|
|
|
if "data" in p and "function" in p["data"]:
|
|
|
|
|
# It's a raw post object
|
|
|
|
|
manifest = p["data"]["function"].get("meta", {}).get("manifest", {})
|
|
|
|
|
title = manifest.get("title") or p.get("title")
|
|
|
|
|
version = manifest.get("version", "0.0.0")
|
2026-01-28 02:14:30 +08:00
|
|
|
file_path = p.get("file_path")
|
|
|
|
|
return title, version, file_path, p
|
2026-01-08 00:14:32 +08:00
|
|
|
else:
|
|
|
|
|
# It's a simple dict
|
2026-01-28 02:14:30 +08:00
|
|
|
return p.get("title"), p.get("version"), p.get("file_path"), p
|
2026-01-08 00:14:32 +08:00
|
|
|
|
2026-01-28 02:14:30 +08:00
|
|
|
current_by_key = {}
|
2026-01-08 00:14:32 +08:00
|
|
|
for p in current:
|
2026-01-28 02:14:30 +08:00
|
|
|
title, _, file_path, _ = get_info(p)
|
|
|
|
|
key = file_path or title
|
|
|
|
|
if key:
|
|
|
|
|
current_by_key[key] = p
|
2026-01-08 00:14:32 +08:00
|
|
|
|
2026-01-28 02:14:30 +08:00
|
|
|
previous_by_key = {}
|
2026-01-08 00:14:32 +08:00
|
|
|
for p in previous:
|
2026-01-28 02:14:30 +08:00
|
|
|
title, _, file_path, _ = get_info(p)
|
|
|
|
|
key = file_path or title
|
|
|
|
|
if key:
|
|
|
|
|
previous_by_key[key] = p
|
2025-12-31 11:14:41 +00:00
|
|
|
|
|
|
|
|
result = {"added": [], "updated": [], "removed": []}
|
|
|
|
|
|
|
|
|
|
# Find added and updated plugins
|
2026-01-28 02:14:30 +08:00
|
|
|
for key, plugin in current_by_key.items():
|
|
|
|
|
curr_title, curr_ver, _file_path, _ = get_info(plugin)
|
2026-01-08 00:14:32 +08:00
|
|
|
|
2026-01-28 02:14:30 +08:00
|
|
|
if key not in previous_by_key:
|
2025-12-31 11:14:41 +00:00
|
|
|
result["added"].append(plugin)
|
2026-01-08 00:14:32 +08:00
|
|
|
else:
|
2026-01-28 02:14:30 +08:00
|
|
|
prev_plugin = previous_by_key[key]
|
|
|
|
|
_, prev_ver, _prev_file_path, _ = get_info(prev_plugin)
|
2026-01-08 00:14:32 +08:00
|
|
|
|
|
|
|
|
if curr_ver != prev_ver:
|
|
|
|
|
result["updated"].append(
|
|
|
|
|
{
|
|
|
|
|
"current": plugin,
|
|
|
|
|
"previous": prev_plugin,
|
|
|
|
|
}
|
|
|
|
|
)
|
2025-12-31 11:14:41 +00:00
|
|
|
|
|
|
|
|
# Find removed plugins
|
2026-01-28 02:14:30 +08:00
|
|
|
for key, plugin in previous_by_key.items():
|
|
|
|
|
if key not in current_by_key:
|
2025-12-31 11:14:41 +00:00
|
|
|
result["removed"].append(plugin)
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_markdown_table(plugins: list[dict]) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Format plugins as a Markdown table.
|
|
|
|
|
将插件格式化为 Markdown 表格。
|
|
|
|
|
"""
|
|
|
|
|
lines = [
|
|
|
|
|
"| Plugin / 插件 | Version / 版本 | Type / 类型 | Description / 描述 |",
|
|
|
|
|
"|---------------|----------------|-------------|---------------------|",
|
|
|
|
|
]
|
|
|
|
|
|
2026-01-03 10:55:05 +08:00
|
|
|
for plugin in sorted(
|
|
|
|
|
plugins, key=lambda x: (x.get("type", ""), x.get("title", ""))
|
|
|
|
|
):
|
2025-12-31 11:14:41 +00:00
|
|
|
title = plugin.get("title", "Unknown")
|
|
|
|
|
version = plugin.get("version", "Unknown")
|
|
|
|
|
plugin_type = plugin.get("type", "Unknown").capitalize()
|
|
|
|
|
full_description = plugin.get("description", "")
|
|
|
|
|
description = full_description[:50]
|
|
|
|
|
if len(full_description) > 50:
|
|
|
|
|
description += "..."
|
|
|
|
|
lines.append(f"| {title} | {version} | {plugin_type} | {description} |")
|
|
|
|
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
2026-01-20 20:35:06 +08:00
|
|
|
def _get_readme_url(file_path: str) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Generate GitHub README URL from plugin file path.
|
|
|
|
|
从插件文件路径生成 GitHub README 链接。
|
|
|
|
|
"""
|
|
|
|
|
if not file_path:
|
|
|
|
|
return ""
|
|
|
|
|
# Extract plugin directory (e.g., plugins/filters/folder-memory/folder_memory.py -> plugins/filters/folder-memory)
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
plugin_dir = Path(file_path).parent
|
|
|
|
|
# Convert to GitHub URL
|
|
|
|
|
return (
|
|
|
|
|
f"https://github.com/Fu-Jie/awesome-openwebui/blob/main/{plugin_dir}/README.md"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-01-03 10:55:05 +08:00
|
|
|
def format_release_notes(
|
|
|
|
|
comparison: dict[str, list], ignore_removed: bool = False
|
|
|
|
|
) -> str:
|
2025-12-31 11:14:41 +00:00
|
|
|
"""
|
|
|
|
|
Format version comparison as release notes.
|
|
|
|
|
将版本比较格式化为发布说明。
|
|
|
|
|
"""
|
|
|
|
|
lines = []
|
|
|
|
|
|
|
|
|
|
if comparison["added"]:
|
|
|
|
|
lines.append("### 新增插件 / New Plugins")
|
|
|
|
|
for plugin in comparison["added"]:
|
2026-01-20 20:35:06 +08:00
|
|
|
readme_url = _get_readme_url(plugin.get("file_path", ""))
|
2025-12-31 11:14:41 +00:00
|
|
|
lines.append(f"- **{plugin['title']}** v{plugin['version']}")
|
|
|
|
|
if plugin.get("description"):
|
|
|
|
|
lines.append(f" - {plugin['description']}")
|
2026-01-20 20:35:06 +08:00
|
|
|
if readme_url:
|
|
|
|
|
lines.append(f" - 📖 [README / 文档]({readme_url})")
|
2025-12-31 11:14:41 +00:00
|
|
|
lines.append("")
|
|
|
|
|
|
|
|
|
|
if comparison["updated"]:
|
|
|
|
|
lines.append("### 插件更新 / Plugin Updates")
|
|
|
|
|
for update in comparison["updated"]:
|
|
|
|
|
curr = update["current"]
|
|
|
|
|
prev = update["previous"]
|
2026-01-08 00:14:32 +08:00
|
|
|
|
|
|
|
|
# Extract info safely
|
|
|
|
|
curr_manifest = (
|
|
|
|
|
curr.get("data", {})
|
|
|
|
|
.get("function", {})
|
|
|
|
|
.get("meta", {})
|
|
|
|
|
.get("manifest", {})
|
2025-12-31 11:14:41 +00:00
|
|
|
)
|
2026-01-08 00:14:32 +08:00
|
|
|
curr_title = curr_manifest.get("title") or curr.get("title")
|
|
|
|
|
curr_ver = curr_manifest.get("version") or curr.get("version")
|
|
|
|
|
|
|
|
|
|
prev_manifest = (
|
|
|
|
|
prev.get("data", {})
|
|
|
|
|
.get("function", {})
|
|
|
|
|
.get("meta", {})
|
|
|
|
|
.get("manifest", {})
|
|
|
|
|
)
|
|
|
|
|
prev_ver = prev_manifest.get("version") or prev.get("version")
|
|
|
|
|
|
2026-01-20 20:35:06 +08:00
|
|
|
readme_url = _get_readme_url(curr.get("file_path", ""))
|
2026-01-08 00:14:32 +08:00
|
|
|
lines.append(f"- **{curr_title}**: v{prev_ver} → v{curr_ver}")
|
2026-01-20 20:35:06 +08:00
|
|
|
if readme_url:
|
|
|
|
|
lines.append(f" - 📖 [README / 文档]({readme_url})")
|
2025-12-31 11:14:41 +00:00
|
|
|
lines.append("")
|
|
|
|
|
|
2026-01-03 10:55:05 +08:00
|
|
|
if comparison["removed"] and not ignore_removed:
|
2025-12-31 11:14:41 +00:00
|
|
|
lines.append("### 移除插件 / Removed Plugins")
|
|
|
|
|
for plugin in comparison["removed"]:
|
|
|
|
|
lines.append(f"- **{plugin['title']}** v{plugin['version']}")
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description="Extract and compare plugin version information"
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--plugins-dir",
|
|
|
|
|
default="plugins",
|
|
|
|
|
help="Path to plugins directory (default: plugins)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--json",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Output as JSON",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--markdown",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Output as Markdown table",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--compare",
|
|
|
|
|
metavar="FILE",
|
|
|
|
|
help="Compare with previous version JSON file",
|
|
|
|
|
)
|
2026-01-03 10:55:05 +08:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--ignore-removed",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Ignore removed plugins in output",
|
|
|
|
|
)
|
2025-12-31 11:14:41 +00:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--output",
|
|
|
|
|
"-o",
|
|
|
|
|
metavar="FILE",
|
|
|
|
|
help="Write output to file instead of stdout",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
# Scan plugins
|
|
|
|
|
plugins = scan_plugins_directory(args.plugins_dir)
|
|
|
|
|
|
|
|
|
|
# Generate output
|
|
|
|
|
if args.compare:
|
|
|
|
|
comparison = compare_versions(plugins, args.compare)
|
|
|
|
|
if args.json:
|
|
|
|
|
output = json.dumps(comparison, indent=2, ensure_ascii=False)
|
|
|
|
|
else:
|
2026-01-03 10:55:05 +08:00
|
|
|
output = format_release_notes(
|
|
|
|
|
comparison, ignore_removed=args.ignore_removed
|
|
|
|
|
)
|
2025-12-31 11:14:41 +00:00
|
|
|
if not output.strip():
|
|
|
|
|
output = "No changes detected. / 未检测到更改。"
|
|
|
|
|
elif args.json:
|
|
|
|
|
output = json.dumps(plugins, indent=2, ensure_ascii=False)
|
|
|
|
|
elif args.markdown:
|
|
|
|
|
output = format_markdown_table(plugins)
|
|
|
|
|
else:
|
|
|
|
|
# Default: simple list
|
|
|
|
|
lines = []
|
|
|
|
|
for plugin in sorted(plugins, key=lambda x: x.get("title", "")):
|
2026-01-03 10:55:05 +08:00
|
|
|
lines.append(
|
|
|
|
|
f"{plugin.get('title', 'Unknown')}: v{plugin.get('version', '?')}"
|
|
|
|
|
)
|
2025-12-31 11:14:41 +00:00
|
|
|
output = "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
# Write output
|
|
|
|
|
if args.output:
|
|
|
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
|
|
|
f.write(output)
|
2026-01-03 16:00:50 +08:00
|
|
|
if not output.endswith("\n"):
|
|
|
|
|
f.write("\n")
|
2025-12-31 11:14:41 +00:00
|
|
|
print(f"Output written to {args.output}")
|
|
|
|
|
else:
|
|
|
|
|
print(output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|