a13rollcall/plugin_manager.py
2026-09-26 20:17:31 +08:00

861 行
30 KiB
Python

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import os
import json
import sys
import traceback
from datetime import datetime
from collections import defaultdict
class PluginLogger:
def __init__(self, plugin_name, log_dir):
self.plugin_name = plugin_name
self.log_dir = log_dir
self.log_file = os.path.join(log_dir, f"{plugin_name}.log")
self._ensure_dir()
def _ensure_dir(self):
if not os.path.exists(self.log_dir):
os.makedirs(self.log_dir)
def _write(self, level, message):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_line = f"[{timestamp}] [{level}] {message}\n"
try:
with open(self.log_file, "a", encoding="utf-8") as f:
f.write(log_line)
except Exception:
pass
def info(self, message):
self._write("INFO", message)
def warning(self, message):
self._write("WARNING", message)
def error(self, message):
self._write("ERROR", message)
def debug(self, message):
self._write("DEBUG", message)
def get_logs(self, lines=100):
if not os.path.exists(self.log_file):
return []
try:
with open(self.log_file, "r", encoding="utf-8") as f:
all_lines = f.readlines()
return all_lines[-lines:]
except Exception:
return []
def clear(self):
if os.path.exists(self.log_file):
os.remove(self.log_file)
class PluginDataManager:
def __init__(self, plugin_name, data_dir):
self.plugin_name = plugin_name
self.data_dir = data_dir
self.config_file = os.path.join(data_dir, "config.json")
self._ensure_dir()
def _ensure_dir(self):
if not os.path.exists(self.data_dir):
os.makedirs(self.data_dir)
def load_config(self):
if not os.path.exists(self.config_file):
return {}
try:
with open(self.config_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def save_config(self, config):
try:
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
def get(self, key, default=None):
config = self.load_config()
return config.get(key, default)
def set(self, key, value):
config = self.load_config()
config[key] = value
return self.save_config(config)
def get_data_file(self, filename):
return os.path.join(self.data_dir, filename)
def read_data(self, filename):
file_path = self.get_data_file(filename)
if not os.path.exists(file_path):
return None
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return None
def write_data(self, filename, content):
file_path = self.get_data_file(filename)
try:
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
return True
except Exception:
return False
def read_json(self, filename):
content = self.read_data(filename)
if content:
try:
return json.loads(content)
except Exception:
return None
return None
def write_json(self, filename, data):
try:
content = json.dumps(data, ensure_ascii=False, indent=2)
return self.write_data(filename, content)
except Exception:
return False
class ArcxParser:
MAGIC = "ARCX"
VERSION = 1
@staticmethod
def parse(file_path):
if not os.path.exists(file_path):
return None, "文件不存在"
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
if content.startswith("ARCX"):
return ArcxParser._parse_binary(content)
else:
data = json.loads(content)
return ArcxParser._validate(data), None
except json.JSONDecodeError as e:
return None, f"JSON 解析错误:{str(e)}"
except Exception as e:
return None, f"解析失败:{str(e)}"
@staticmethod
def _parse_binary(content):
try:
lines = content.split("\n")
meta = {}
code_start = -1
for i, line in enumerate(lines):
if line.startswith("META:"):
meta_part = line[5:].strip()
meta = json.loads(meta_part)
elif line.startswith("CODE:"):
code_start = i + 1
break
code = "\n".join(lines[code_start:]) if code_start > 0 else ""
data = {
"meta": meta,
"code": code,
"config": meta.get("config", {})
}
return ArcxParser._validate(data), None
except Exception as e:
return None, f"二进制格式解析失败:{str(e)}"
@staticmethod
def _validate(data):
required = ["meta", "code"]
for key in required:
if key not in data:
data[key] = {} if key == "meta" else ""
meta = data.get("meta", {})
meta.setdefault("name", "未命名插件")
meta.setdefault("version", "1.0.0")
meta.setdefault("author", "未知")
meta.setdefault("description", "")
meta.setdefault("type", "general")
meta.setdefault("api_version", 1)
meta.setdefault("has_settings_page", False)
meta.setdefault("settings_page_title", "")
data["meta"] = meta
data.setdefault("config", {})
data.setdefault("enabled", True)
data.setdefault("installed_at", datetime.now().isoformat())
return data
@staticmethod
def create_arcx(name, description, code, config=None, author="自定义", version="1.0.0", has_settings_page=False):
data = {
"meta": {
"name": name,
"version": version,
"author": author,
"description": description,
"type": "general",
"api_version": 1,
"has_settings_page": has_settings_page,
"settings_page_title": name,
"config": config or {}
},
"code": code,
"config": config or {}
}
return json.dumps(data, ensure_ascii=False, indent=2)
class PluginContext:
def __init__(self, plugin, app, plugin_mgr):
self.plugin = plugin
self.app = app
self.plugin_mgr = plugin_mgr
self._variables = {}
self._ui_elements = []
self._event_handlers = defaultdict(list)
self._loaded_libs = []
plugin_name = plugin["meta"]["name"]
base_dir = os.path.dirname(os.path.abspath(__file__))
self.data_dir = os.path.join(base_dir, "plugins", "data", plugin_name)
self.libs_dir = os.path.join(self.data_dir, "libs")
self.log_dir = os.path.join(base_dir, "plugins", "logs")
self.logger = PluginLogger(plugin_name, self.log_dir)
self.data = PluginDataManager(plugin_name, self.data_dir)
self._load_plugin_libs()
self.logger.info(f"插件上下文初始化完成,数据目录:{self.data_dir}")
def _load_plugin_libs(self):
"""加载插件自带的依赖库到sys.path"""
if os.path.exists(self.libs_dir):
if self.libs_dir not in sys.path:
sys.path.insert(0, self.libs_dir)
self._loaded_libs.append(self.libs_dir)
self.logger.info(f"已加载插件依赖库目录:{self.libs_dir}")
# 检查libs目录下的内容
try:
lib_items = os.listdir(self.libs_dir)
self.logger.info(f"插件依赖库包含 {len(lib_items)} 个项目:{', '.join(lib_items[:10])}")
except Exception as e:
self.logger.warning(f"读取依赖库目录失败:{e}")
def get_libs_dir(self):
"""获取插件依赖库目录"""
return self.libs_dir
def has_lib(self, lib_name):
"""检查插件是否自带某个依赖库"""
lib_path = os.path.join(self.libs_dir, lib_name)
return os.path.exists(lib_path) or os.path.exists(lib_path + ".py")
def list_libs(self):
"""列出插件自带的所有依赖库"""
if not os.path.exists(self.libs_dir):
return []
try:
return os.listdir(self.libs_dir)
except Exception:
return []
def set(self, key, value):
self._variables[key] = value
self.logger.debug(f"设置变量 {key} = {value}")
def get(self, key, default=None):
return self._variables.get(key, default)
def show_message(self, title, message):
from PyQt5.QtWidgets import QMessageBox
self.logger.info(f"显示消息框:{title}")
QMessageBox.information(None, title, message)
def show_custom_dialog(self, title, content, buttons=None):
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout
self.logger.info(f"显示自定义对话框:{title}")
dlg = QDialog()
dlg.setWindowTitle(title)
dlg.setMinimumSize(400, 300)
layout = QVBoxLayout(dlg)
if isinstance(content, str):
label = QLabel(content)
label.setWordWrap(True)
layout.addWidget(label)
btn_layout = QHBoxLayout()
buttons = buttons or ["确定"]
for btn_text in buttons:
btn = QPushButton(btn_text)
btn.clicked.connect(dlg.accept)
btn_layout.addWidget(btn)
layout.addLayout(btn_layout)
dlg.exec_()
return True
def get_config(self, key, default=None):
return self.data.get(key, default)
def set_config(self, key, value):
self.logger.info(f"保存配置 {key} = {value}")
return self.data.set(key, value)
def is_frozen(self):
"""检测是否在打包环境(PyInstaller)中运行"""
return getattr(sys, 'frozen', False)
def get_app_dir(self):
"""获取应用程序目录(兼容打包环境和源码运行)"""
if self.is_frozen():
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def get_python_version(self):
"""获取Python版本信息"""
return {
"version": sys.version,
"version_info": {
"major": sys.version_info.major,
"minor": sys.version_info.minor,
"micro": sys.version_info.micro,
},
"executable": sys.executable,
"frozen": self.is_frozen()
}
def check_dependency(self, module_name):
"""检查依赖模块是否可用"""
try:
__import__(module_name)
return True
except ImportError:
return False
def get_available_modules(self):
"""获取常用模块的可用状态"""
common_modules = [
"os", "sys", "json", "re", "math", "random", "datetime",
"subprocess", "threading", "socket", "urllib", "http",
"PyQt5", "PyQt5.QtCore", "PyQt5.QtGui", "PyQt5.QtWidgets",
"numpy", "requests", "PIL", "cv2", "pandas", "matplotlib",
"websockets", "flask", "serial", "pygame"
]
available = {}
for module in common_modules:
available[module] = self.check_dependency(module)
return available
def get_resource_path(self, relative_path):
"""获取资源文件路径(兼容打包环境)"""
if self.is_frozen():
base_path = sys._MEIPASS if hasattr(sys, '_MEIPASS') else self.get_app_dir()
return os.path.join(base_path, relative_path)
return os.path.join(self.get_app_dir(), relative_path)
def get_students(self):
if hasattr(self.app, '_students'):
return self.app._students
return []
def get_current_student(self):
if hasattr(self.app, '_current_student'):
return self.app._current_student
return None
def get_last_draw_result(self):
if hasattr(self.app, '_last_draw_result') and self.app._last_draw_result:
return self.app._last_draw_result
return None
def get_window_state(self):
is_drawing = False
is_minimized = False
is_quick_mode = False
state = "main"
current_window = "主界面"
if hasattr(self.app, '_is_drawing'):
is_drawing = self.app._is_drawing
if hasattr(self.app, 'isMinimized'):
try:
is_minimized = self.app.isMinimized()
except Exception:
pass
if hasattr(self.app, '_quick_mode'):
is_quick_mode = self.app._quick_mode
if is_minimized:
state = "minimized"
current_window = "最小化"
elif is_drawing:
state = "drawing"
current_window = "正在抽取"
elif is_quick_mode:
state = "quick"
current_window = "快捷抽取"
else:
state = "main"
current_window = "主界面"
return {
"state": state,
"is_drawing": is_drawing,
"is_minimized": is_minimized,
"is_quick_mode": is_quick_mode,
"current_window": current_window
}
def trigger_draw(self):
self.logger.info("触发抽取")
if hasattr(self.app, '_start_draw'):
self.app._start_draw()
def add_status_bar_text(self, text):
self.logger.debug(f"状态栏文本:{text}")
if hasattr(self.app, 'statusBar'):
self.app.statusBar().showMessage(text, 5000)
def log(self, message):
self.logger.info(message)
def register_event_handler(self, event_name, handler):
self._event_handlers[event_name].append(handler)
self.logger.info(f"注册事件处理器:{event_name}")
def emit_event(self, event_name, *args, **kwargs):
self.logger.debug(f"触发事件:{event_name}")
for handler in self._event_handlers.get(event_name, []):
try:
handler(*args, **kwargs)
except Exception as e:
self.logger.error(f"事件处理器执行错误:{str(e)}")
def get_logs(self, lines=100):
return self.logger.get_logs(lines)
def clear_logs(self):
self.logger.clear()
self.logger.info("日志已清空")
def get_app_config(self, key, default=None):
if hasattr(self.app, 'config_mgr') and self.app.config_mgr:
try:
value = self.app.config_mgr.get(key)
if value is not None:
return value
except Exception:
pass
return default
def set_app_config(self, key, value):
if hasattr(self.app, 'config_mgr') and self.app.config_mgr:
self.app.config_mgr.set(key, value)
self.logger.info(f"修改应用配置 {key} = {value}")
return True
return False
def restart_application(self, delay=1000):
self.logger.info("请求重启应用")
if hasattr(self.app, '_restart_application'):
from PyQt5.QtCore import QTimer
QTimer.singleShot(delay, self.app._restart_application)
return True
return False
def get_theme(self):
if hasattr(self.app, 'theme_mgr') and self.app.theme_mgr:
if hasattr(self.app.theme_mgr, 'current_theme'):
return self.app.theme_mgr.current_theme
if hasattr(self.app.theme_mgr, '_current_theme'):
return self.app.theme_mgr._current_theme
return "light"
def set_theme(self, theme_name):
if hasattr(self.app, 'theme_mgr') and self.app.theme_mgr:
self.app.theme_mgr.set_theme(theme_name)
self.logger.info(f"切换主题到 {theme_name}")
return True
return False
def get_class_name(self):
if hasattr(self.app, '_get_class_name'):
return self.app._get_class_name()
return "课堂点名"
def show_toast(self, message, duration=2000):
self.logger.info(f"显示提示:{message}")
if hasattr(self.app, '_show_toast'):
self.app._show_toast(message, duration)
return True
return False
def render_markdown(self, markdown_text):
try:
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from main_qt import MarkdownRenderer
return MarkdownRenderer.render(markdown_text)
except Exception as e:
self.logger.error(f"Markdown 渲染失败:{str(e)}")
return markdown_text
def create_markdown_view(self, parent=None):
try:
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from main_qt import MarkdownView
return MarkdownView(parent)
except Exception as e:
self.logger.error(f"创建 Markdown 视图失败:{str(e)}")
from PyQt5.QtWidgets import QTextEdit
view = QTextEdit(parent)
view.setReadOnly(True)
return view
def get_plugin_pages(self):
meta = self.plugin.get("meta", {})
return meta.get("pages", [])
def build_plugin_page(self, page_key, parent=None):
try:
code = self.plugin.get("code", "")
if not code.strip():
self.logger.error(f"插件代码为空")
return None
exec_globals = self._get_exec_globals()
try:
exec(code, exec_globals)
except Exception as e:
self.logger.error(f"执行插件代码失败:{str(e)}\n{traceback.format_exc()}")
return None
func_name = f"build_page_{page_key}"
if func_name not in exec_globals:
available = [k for k in exec_globals.keys() if k.startswith('build_')]
self.logger.error(f"未找到函数 {func_name},可用函数:{available}")
return None
func = exec_globals[func_name]
if not callable(func):
self.logger.error(f"{func_name} 不是可调用对象")
return None
try:
if parent:
return func(self, parent)
return func(self)
except Exception as e:
self.logger.error(f"调用 {func_name} 失败:{str(e)}\n{traceback.format_exc()}")
return None
except Exception as e:
self.logger.error(f"构建插件页面 {page_key} 失败:{str(e)}\n{traceback.format_exc()}")
return None
def _get_exec_globals(self):
return {
'context': self,
'plugin': self.plugin,
'app': self.app,
'print': self.logger.info,
'__name__': 'plugin_exec',
}
class PluginManager:
def __init__(self, config_mgr):
self.config_mgr = config_mgr
self.base_dir = os.path.dirname(os.path.abspath(__file__))
self.plugins_dir = os.path.join(self.base_dir, "plugins")
self.plugins_data_dir = os.path.join(self.plugins_dir, "data")
self.plugins_log_dir = os.path.join(self.plugins_dir, "logs")
self.plugins = {}
self.contexts = {}
self._ensure_dirs()
self.load_all_plugins()
def _ensure_dirs(self):
for d in [self.plugins_dir, self.plugins_data_dir, self.plugins_log_dir]:
if not os.path.exists(d):
os.makedirs(d)
def _get_state_file(self):
return os.path.join(self.plugins_dir, "plugin_states.json")
def _save_plugin_state(self, name, enabled):
try:
states = {}
state_file = self._get_state_file()
if os.path.exists(state_file):
with open(state_file, "r", encoding="utf-8") as f:
states = json.load(f)
states[name] = {"enabled": enabled, "updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
with open(state_file, "w", encoding="utf-8") as f:
json.dump(states, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存插件状态失败:{e}")
def _load_plugin_states(self):
try:
state_file = self._get_state_file()
if not os.path.exists(state_file):
return {}
with open(state_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"加载插件状态失败:{e}")
return {}
def load_all_plugins(self):
self.plugins = {}
if not os.path.exists(self.plugins_dir):
return
states = self._load_plugin_states()
for filename in os.listdir(self.plugins_dir):
if filename.endswith(".arcx"):
file_path = os.path.join(self.plugins_dir, filename)
plugin, error = ArcxParser.parse(file_path)
if plugin:
plugin["file_path"] = file_path
plugin["filename"] = filename
plugin_name = plugin["meta"]["name"]
if plugin_name in states:
plugin["enabled"] = states[plugin_name].get("enabled", True)
else:
plugin["enabled"] = True
self.plugins[plugin_name] = plugin
else:
print(f"插件加载失败 {filename}: {error}")
def get_plugin(self, name):
return self.plugins.get(name)
def get_all_plugins(self):
return list(self.plugins.values())
def get_enabled_plugins(self):
return [p for p in self.plugins.values() if p.get("enabled", True)]
def get_plugins_with_settings(self):
return [p for p in self.get_enabled_plugins() if p["meta"].get("has_settings_page", False)]
def get_plugin_pages(self, plugin_name):
plugin = self.get_plugin(plugin_name)
if not plugin:
return []
meta = plugin.get("meta", {})
pages = meta.get("pages", [])
if not pages and meta.get("has_settings_page", False):
pages = [{"key": "settings", "title": "设置", "icon": "settings"}]
return pages
def build_plugin_page(self, plugin_name, page_key, app, parent=None):
context = self.get_context(plugin_name, app)
if not context:
return None
return context.build_plugin_page(page_key, parent)
def enable_plugin(self, name):
if name in self.plugins:
self.plugins[name]["enabled"] = True
self._call_lifecycle(name, "on_enable")
return True
return False
def disable_plugin(self, name):
if name in self.plugins:
self._call_lifecycle(name, "on_disable")
self.plugins[name]["enabled"] = False
return True
return False
def install_plugin(self, source_path):
if not os.path.exists(source_path):
return False, "源文件不存在"
plugin, error = ArcxParser.parse(source_path)
if not plugin:
return False, error
filename = os.path.basename(source_path)
dest_path = os.path.join(self.plugins_dir, filename)
import shutil
shutil.copy2(source_path, dest_path)
plugin["file_path"] = dest_path
plugin["filename"] = filename
self.plugins[plugin["meta"]["name"]] = plugin
self._call_lifecycle(plugin["meta"]["name"], "on_install")
return True, f"插件 {plugin['meta']['name']} 安装成功"
def uninstall_plugin(self, name):
if name not in self.plugins:
return False, "插件不存在"
self._call_lifecycle(name, "on_uninstall")
plugin = self.plugins[name]
file_path = plugin.get("file_path")
if file_path and os.path.exists(file_path):
os.remove(file_path)
data_dir = os.path.join(self.plugins_data_dir, name)
if os.path.exists(data_dir):
import shutil
shutil.rmtree(data_dir, ignore_errors=True)
del self.plugins[name]
if name in self.contexts:
del self.contexts[name]
return True, f"插件 {name} 已卸载"
def get_context(self, name, app):
if name not in self.plugins:
return None
if name not in self.contexts:
self.contexts[name] = PluginContext(self.plugins[name], app, self)
return self.contexts[name]
def execute_plugin(self, name, app, entry_point="main"):
plugin = self.get_plugin(name)
if not plugin:
return None, "插件不存在"
if not plugin.get("enabled", True):
return None, "插件已禁用"
context = self.get_context(name, app)
try:
code = plugin.get("code", "")
if not code.strip():
return None, "插件代码为空"
exec_globals = {
"context": context,
"plugin": plugin,
"app": app,
"config": plugin.get("config", {}),
"print": context.log,
"__name__": f"plugin_{name}"
}
context.logger.info(f"开始执行插件,入口:{entry_point}")
exec(code, exec_globals)
if entry_point in exec_globals and callable(exec_globals[entry_point]):
result = exec_globals[entry_point](context)
context.logger.info(f"插件执行完成,返回:{result}")
return result, None
context.logger.info("插件执行完成(无入口函数)")
return None, None
except Exception as e:
error_msg = f"插件执行错误:{str(e)}\n{traceback.format_exc()}"
context.logger.error(error_msg)
return None, error_msg
def _call_lifecycle(self, name, method_name):
plugin = self.get_plugin(name)
if not plugin or not plugin.get("enabled", True):
return
context = self.contexts.get(name)
if not context:
return
try:
code = plugin.get("code", "")
exec_globals = {
"context": context,
"plugin": plugin,
"print": context.log,
"__name__": f"plugin_{name}"
}
exec(code, exec_globals)
if method_name in exec_globals and callable(exec_globals[method_name]):
context.logger.info(f"调用生命周期方法:{method_name}")
exec_globals[method_name](context)
except Exception as e:
context.logger.error(f"生命周期方法 {method_name} 执行错误:{str(e)}")
def build_plugin_settings_page(self, name, app):
plugin = self.get_plugin(name)
if not plugin:
return None
context = self.get_context(name, app)
try:
code = plugin.get("code", "")
exec_globals = {
"context": context,
"plugin": plugin,
"print": context.log,
"__name__": f"plugin_{name}"
}
exec(code, exec_globals)
if "build_settings_page" in exec_globals and callable(exec_globals["build_settings_page"]):
context.logger.info("构建设置页面")
return exec_globals["build_settings_page"](context)
except Exception as e:
context.logger.error(f"构建设置页面错误:{str(e)}")
return None
def create_sample_plugin(self):
sample_code = '''
def main(context):
context.show_message("示例插件", "这是一个示例插件!\\n\\n你可以在这里编写自己的插件逻辑。")
students = context.get_students()
context.log(f"当前有 {len(students)} 名学生")
context.set_config("last_run", str(datetime.now()))
return {"status": "success", "student_count": len(students)}
def build_settings_page(context):
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(12)
title = QLabel("示例插件设置")
title.setStyleSheet("font-size: 18px; font-weight: 700; color: #0067c0;")
layout.addWidget(title)
desc = QLabel("在这里可以配置示例插件的各项参数。\\n配置会自动保存到插件数据目录的 config.json 中。")
desc.setWordWrap(True)
desc.setStyleSheet("color: #666666; font-size: 12px;")
layout.addWidget(desc)
input_label = QLabel("欢迎语:")
input_label.setStyleSheet("font-size: 13px; font-weight: 600;")
layout.addWidget(input_label)
welcome_input = QLineEdit()
welcome_input.setText(context.get_config("welcome_message", "欢迎使用示例插件!"))
layout.addWidget(welcome_input)
def save_config():
context.set_config("welcome_message", welcome_input.text())
context.show_message("保存成功", "配置已保存!")
save_btn = QPushButton("保存配置")
save_btn.setStyleSheet("QPushButton { background: #0067c0; color: white; border: none; border-radius: 6px; padding: 10px 20px; font-weight: 600; }")
save_btn.clicked.connect(save_config)
layout.addWidget(save_btn)
layout.addStretch()
return page
def on_enable(context):
context.log("插件已启用")
def on_disable(context):
context.log("插件已禁用")
def on_install(context):
context.log("插件已安装")
def on_uninstall(context):
context.log("插件已卸载")
'''
return ArcxParser.create_arcx(
name="示例插件",
description="这是一个示例插件,展示插件的基本结构、设置页面、日志系统和数据存储。",
code=sample_code,
config={"welcome_message": "欢迎使用示例插件!", "show_student_count": True},
author="A13",
version="1.0.0",
has_settings_page=True
)