feat: 插件管理 - #2097
Conversation
📝 Walkthrough新增
变更
安全
Walkthrough新增 Changes插件导入与管理
Estimated code review effort: 4 (复杂) | ~60 分钟 Sequence Diagram(s)sequenceDiagram
actor User
participant SettingPluginInterface
participant PluginImportService
participant FileSystem
participant ApplicationFactoryManager
User->>SettingPluginInterface: 选择 ZIP 或插件目录
SettingPluginInterface->>PluginImportService: 请求预览
PluginImportService->>FileSystem: 分析结构并读取插件元数据
FileSystem-->>PluginImportService: 返回预览信息
PluginImportService-->>SettingPluginInterface: 返回 PluginPreviewInfo
User->>SettingPluginInterface: 确认导入或覆盖
SettingPluginInterface->>PluginImportService: 导入选定插件
PluginImportService->>FileSystem: 校验、临时写入并替换目标目录
FileSystem-->>PluginImportService: 返回安装结果
PluginImportService-->>SettingPluginInterface: 返回 ImportResult
SettingPluginInterface->>ApplicationFactoryManager: 刷新应用注册
ApplicationFactoryManager-->>SettingPluginInterface: 返回更新后的插件列表
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 143-168: plugin_dir_name and extraction currently trust ZIP
entries and can escape plugins_dir; update _get_plugin_dir_name to normalize and
sanitize the candidate name (strip leading slashes, reject or collapse any '..'
segments) and ensure it returns a safe single directory basename, then before
using it compute target_dir = (self.plugins_dir / plugin_dir_name).resolve() and
verify target_dir.is_relative_to(self.plugins_dir.resolve()) (or compare parent
prefixes) to prevent path traversal; similarly, in _extract_plugin validate each
member path by joining to target_dir, resolving, and ensuring the resolved path
remains under plugins_dir before writing, and refuse/skip any absolute or '..'
entries to avoid deleting or writing outside the intended directory (also guard
the overwrite rmtree by verifying target_dir is inside plugins_dir).
- Around line 154-169: The code currently deletes the existing plugin directory
(target_dir) before extracting new contents, which can cause data loss if
extraction fails; change the logic in the import flow (the block around
target_dir, plugin_dir_name, overwrite and calls to self._extract_plugin) to
extract into a temporary directory first (e.g., tmp_target_dir), perform all
validation/checks there, and only after successful extraction/verification
atomically replace the old plugin directory (move/rename tmp_target_dir over
target_dir or remove old and rename tmp_target_dir) to avoid losing the
currently installed version; apply the same pattern to the other similar block
referenced (the second rmtree usage around lines 379-396) so both code paths use
temp extraction + atomic swap instead of rmtree before extraction.
- Around line 205-214: The import validation currently only checks for
*_factory.py (has_factory) and returns ImportResult; add a parallel hard-check
for the plugin const module (e.g., compute has_const =
any(name.endswith('_const.py') for name in file_list)) and if missing return
ImportResult(success=False, plugin_name="", message="无效的插件结构:缺少 *_const.py 文件");
apply the same change to the other validation/preview branches that mirror this
logic (the other ImportResult return sites in this module) so ZIP/dir import and
preview all enforce *_const.py presence, since
application_factory_manager._read_plugin_metadata requires the const module and
will raise ImportError otherwise.
- Around line 457-481: The current parent-directory check on plugin_dir is
bypassable and allows deleting the plugins root; fix by normalizing paths before
checks: call plugin_dir_resolved = Path(plugin_dir).resolve(strict=False) and
plugins_dir_resolved = self.plugins_dir.resolve(strict=False), use
plugin_dir_resolved.name for plugin_name, then reject if not
plugin_dir_resolved.is_relative_to(plugins_dir_resolved) or if
plugin_dir_resolved == plugins_dir_resolved (i.e., disallow deleting the root);
only after these checks call shutil.rmtree(plugin_dir_resolved) to remove the
directory.
In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py`:
- Around line 323-336: The installed_plugins map uses p.app_id as key but later
code (ImportResult.plugin_name / result.plugin_name from
plugin_import_service.preview_plugin) supplies plugin directory names, so
lookups miss matches causing downgrade/overwrite checks to be skipped; change
the map key to the plugin directory/name used by the import service (use
whatever property on p represents the directory/name — e.g., p.plugin_name or
p.dir_name) so installed_plugins = { <directory-key>: p for p in
self.ctx.factory_manager.third_party_plugins }, then keep the existing logic
that uses preview = self.plugin_import_service.preview_plugin(fp), new_ver =
preview.version, old_ver = installed_plugins.get(r.plugin_name, None),
is_downgrade = self._is_version_lower(...) so comparisons use the same
identifier for installed_plugins, overwrite_info, and the
ImportResult.plugin_name/result.plugin_name lookups.
- Around line 53-56: The PluginCard currently creates homepage_btn only if
plugin_info.homepage exists, causing recycled cards to miss the button; always
create self.homepage_btn (connected to _on_homepage_clicked) in the PluginCard
initialization and remove the conditional creation, then in the card
update/refresh method (the routine that reapplies plugin_info when cards are
reused) set self.homepage_btn.setVisible(bool(plugin_info.homepage)) and update
any related state (e.g., tooltip or URL) so the button appears or hides
correctly when a card is reused for a plugin that does or does not have a
homepage.
- Around line 38-44: The constructors PluginCard.__init__ and
SettingPluginInterface.__init__ are missing precise type annotations: replace
the built-in callable with collections.abc.Callable, annotate parent as
Optional[Any] (or Optional[QWidget] if using PyQt/PySide), and add explicit
return type -> None; import Optional and Any from typing and Callable from
collections.abc and update the callback parameters to use Callable[..., Any] (or
more specific signatures like Callable[[], None] if known); apply the same
changes to the other __init__ overload at lines ~121-133 so all constructors
have complete modern Python 3.11+ annotations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 257533ae-0984-42b9-8f32-50fde7bc6cd3
📒 Files selected for processing (3)
src/one_dragon/base/operation/application/plugin_import_service.pysrc/zzz_od/gui/view/setting/app_setting_interface.pysrc/zzz_od/gui/view/setting/setting_plugin_interface.py
c46426c to
284ccc7
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 316-336: The has_root_dir test is too strict and fails when ZIP
contains extra metadata entries (e.g., __MACOSX, .DS_Store), causing files to be
written under an extra nested plugin folder; update the logic in
plugin_import_service.py around has_root_dir / file_list handling to first
filter file_list to only relevant plugin entries (or compute the common root
prefix among non-metadata entries) and then use that prefix when extracting: if
the archive truly contains a single plugin root, call
zf.extractall(self.plugins_dir) or, when writing members in the loop, strip the
detected root prefix from each member before creating directories/writing files
(adjust the existing branch that uses target_dir, zf.read(member), and dest_path
so dest_path is built from member with the root prefix removed).
- Around line 205-214: 当前 ZIP 校验通过只要任意深度存在 *_factory.py,导致二级嵌套(如
pkg/src/foo_factory.py)被误判为可导入插件;在 plugin_import_service.py
中把有用的检测从任意深度改为仅检查归档根目录或根目录的第一层(即只遍历 zip 根下的文件和直接子目录),更新生成 has_factory
的逻辑以只在这些路径下搜索 *_factory.py,并确保返回的 plugin_name
根据找到的根目录(顶层或第一层目录名)来设置;同样将相同修正应用到文件中另一处相同检查(原注记所指 283-291
区域)以保持行为一致并避免后续导入/预览路径不匹配(参考变量名 has_factory 和返回类型 ImportResult 以定位代码片段)。
- Around line 70-71: The __init__ method in plugin_import_service.py is missing
return and member type annotations; update the signature of __init__ to include
a return type "-> None" and add an explicit annotation for the instance
attribute (self.ctx: OneDragonContext) so that both the constructor signature
and the class member comply with the repo's src/**/*.py typing rules (refer to
the __init__ method and the self.ctx attribute in the PluginImportService
class).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 068bdb22-338a-4d1f-81aa-0f63c6f9c499
📒 Files selected for processing (1)
src/one_dragon/base/operation/application/plugin_import_service.py
284ccc7 to
81c7396
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (7)
src/one_dragon/base/operation/application/plugin_import_service.py (4)
1-14: 📐 Maintainability & Code Quality | 🔵 Trivial请补充对应的测试与开发文档。
本文件包含 ZIP 路径净化、覆盖回滚、删除路径约束等安全关键逻辑。当前 PR 中未见
zzz-od-test/下的对应测试。请为_normalize_zip_member_path、_analyze_zip、_replace_plugin_dir和delete_plugin补充用例,重点覆盖路径穿越、覆盖失败回滚和删除根目录这三类场景。需要我生成测试骨架吗?
As per coding guidelines,
**/*.py: 修改代码后同步更新对应的docs/develop/文档与zzz-od-test/测试。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/one_dragon/base/operation/application/plugin_import_service.py` around lines 1 - 14, 为 application/plugin_import_service.py 中的 _normalize_zip_member_path、_analyze_zip、_replace_plugin_dir 和 delete_plugin 补充 zzz-od-test/ 测试,覆盖 ZIP 路径穿越、覆盖安装失败后的回滚,以及拒绝删除插件根目录等场景;同时按项目规范更新 docs/develop/ 中对应的开发文档,说明这些安全行为。Source: Coding guidelines
88-101: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value
plugins_dir每次访问都重新计算。该属性执行
inspect.getfile与路径拼接,_plugins_root()还会再做一次resolve()。import_plugins批量导入时会重复调用多次。项目根目录在运行期不变,可以用functools.cached_property缓存。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/one_dragon/base/operation/application/plugin_import_service.py` around lines 88 - 101, 将 ApplicationPluginImportService 的 plugins_dir 属性改为使用 functools.cached_property,使 inspect.getfile 和路径拼接仅在首次访问时执行;保留现有项目根目录判断及 plugins 路径结果不变,并确保导入对应的 cached_property。
339-346: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value只剥离
root_prefix顶层目录,其余顶层条目会被原样写入。
_analyze_zip只保证 factory 文件的顶层目录唯一(第 289-291 行),不保证所有成员的顶层目录唯一。如果 ZIP 同时包含my_plugin/和assets/,assets/不匹配root_prefix,会被写成plugins/my_plugin/assets/,与插件根目录约定不符。建议在
_analyze_zip中,当root_prefix不为None时拒绝其它顶层条目,或在_extract_plugin中跳过它们。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/one_dragon/base/operation/application/plugin_import_service.py` around lines 339 - 346, 在 `_analyze_zip` 中处理 `root_prefix` 非空的 ZIP 时,校验每个成员的顶层目录都必须是 `root_prefix`;发现其他顶层条目(如 `assets/`)时拒绝该 ZIP,避免 `_extract_plugin` 将其写入插件根目录。保留现有对 `root_prefix` 目录剥离及空相对路径跳过的逻辑。
197-203: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value移除无调用的
_validate_zip_structure。该方法只在
src/one_dragon/base/operation/application/plugin_import_service.py中声明,且无调用点;import_plugin和preview_plugin都直接使用_analyze_zip。移除能减少冗余代码。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/one_dragon/base/operation/application/plugin_import_service.py` around lines 197 - 203, Remove the unused _validate_zip_structure method from PluginImportService, including its ValueError handling and ImportResult construction; retain the existing _analyze_zip usage in import_plugin and preview_plugin unchanged.src/zzz_od/gui/view/setting/setting_plugin_interface.py (3)
207-215: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
_clear_plugin_cards未被调用,与_refresh_plugin_list内的隐藏逻辑重复。Line 230-232 内联了相同的隐藏逻辑。建议删除
_clear_plugin_cards,或在_refresh_plugin_list中复用它以消除重复。♻️ 建议修改(复用方案)
if not third_party_plugins: # 隐藏所有插件卡片,显示空状态 - for card in self._plugin_cards: - with contextlib.suppress(RuntimeError): - card.hide() + self._clear_plugin_cards() self._empty_card.show()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 207 - 215, Remove the duplicated card-hiding loop from _refresh_plugin_list and invoke _clear_plugin_cards there instead, preserving the existing RuntimeError suppression and behavior of hiding all cards without deleting them.
305-309: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
gt(f'...')会把插值后的字符串当作翻译键。f-string 先完成插值,
gt收到的是含动态内容的完整句子,无法命中任何译文条目。建议先翻译模板,再插值。同样适用于 Line 350-354、366-370、452-456、469-477、490、497、509-517、583、602。♻️ 建议修改
- content=gt(f'即将导入以下插件:\n\n{chr(10).join(preview_lines)}'), + content=f'{gt("即将导入以下插件:")}\n\n{chr(10).join(preview_lines)}', ... - content=gt(f'成功导入 {success_count} 个插件'), + content=gt('成功导入 {count} 个插件').format(count=success_count),Also applies to: 396-404
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 305 - 309, 更新 setting_plugin_interface.py 中生成导入提示的相关逻辑,避免将插值后的完整字符串传给 gt;先翻译包含占位符的固定模板,再插入 preview_lines 等动态内容。对同文件中对应的提示文本(包括 MessageBox、行 350-354、366-370、396-404、452-456、469-477、490、497、509-517、583 和 602)统一采用相同方式,并保持现有显示内容不变。
666-674: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win避免将
packaging降级比较误判为可用后备方案。
packaging在 repo 中未声明为通用依赖,Windows lock entry 下的本地环境不一定可用;except Exception涵盖ImportError后会把ImportError判定为无效版本号并直接返回字符串比较,导致new_ver = '10.0'、old_ver = '9.0'时错误判断为降级。把导入提到顶层、显式处理ImportError,并对非法版本号只返回False。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py` around lines 666 - 674, 更新版本降级判断逻辑及其模块导入:将 packaging.version 提升为顶层依赖并显式处理 ImportError,缺少 packaging 时不要回退到字符串比较;仅在版本解析抛出版本格式异常时返回 False,确保合法版本使用 version.parse 比较,非法版本不判定为降级。
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 331-358: 在 _extract_plugin 中增加解压总容量限制,使用模块级常量
_MAX_EXTRACT_SIZE(512 MiB)作为阈值;遍历成员时仅对非目录项累加
info.file_size,并在实际写入前检查累计值,超过阈值立即抛出 ValueError,避免继续创建或写入文件。
In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py`:
- Around line 179-184: Update the HelpCard initialization in the setting
interface so its url points to the online version of
docs/develop/guides/application_plugin_guide.md instead of an empty string,
while preserving the existing title, content, and action_group.addSettingCard
call.
- Around line 592-595: 在删除流程中更新 `_get_plugin_package_dir` 返回 `None`
的分支,调用现有界面错误提示机制告知用户删除失败;同时将 `delete_plugin` 调用及其后续处理移出条件分支,保持有效插件目录的删除行为不变。
- Around line 565-576: Update _on_open_dir_clicked to open plugins_dir through
the already imported QDesktopServices cross-platform API instead of the
sys.platform branches and os/subprocess calls. Preserve directory creation and
remove the now-unused os, subprocess, and sys imports.
- Around line 166-167: Update the open_market_btn click handler to pass a QUrl
instance to QDesktopServices.openUrl instead of a raw string, preserving the
existing plugin registry URL and button behavior.
---
Nitpick comments:
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 1-14: 为 application/plugin_import_service.py 中的
_normalize_zip_member_path、_analyze_zip、_replace_plugin_dir 和 delete_plugin 补充
zzz-od-test/ 测试,覆盖 ZIP 路径穿越、覆盖安装失败后的回滚,以及拒绝删除插件根目录等场景;同时按项目规范更新 docs/develop/
中对应的开发文档,说明这些安全行为。
- Around line 88-101: 将 ApplicationPluginImportService 的 plugins_dir 属性改为使用
functools.cached_property,使 inspect.getfile 和路径拼接仅在首次访问时执行;保留现有项目根目录判断及 plugins
路径结果不变,并确保导入对应的 cached_property。
- Around line 339-346: 在 `_analyze_zip` 中处理 `root_prefix` 非空的 ZIP
时,校验每个成员的顶层目录都必须是 `root_prefix`;发现其他顶层条目(如 `assets/`)时拒绝该 ZIP,避免
`_extract_plugin` 将其写入插件根目录。保留现有对 `root_prefix` 目录剥离及空相对路径跳过的逻辑。
- Around line 197-203: Remove the unused _validate_zip_structure method from
PluginImportService, including its ValueError handling and ImportResult
construction; retain the existing _analyze_zip usage in import_plugin and
preview_plugin unchanged.
In `@src/zzz_od/gui/view/setting/setting_plugin_interface.py`:
- Around line 207-215: Remove the duplicated card-hiding loop from
_refresh_plugin_list and invoke _clear_plugin_cards there instead, preserving
the existing RuntimeError suppression and behavior of hiding all cards without
deleting them.
- Around line 305-309: 更新 setting_plugin_interface.py
中生成导入提示的相关逻辑,避免将插值后的完整字符串传给 gt;先翻译包含占位符的固定模板,再插入 preview_lines
等动态内容。对同文件中对应的提示文本(包括 MessageBox、行
350-354、366-370、396-404、452-456、469-477、490、497、509-517、583 和
602)统一采用相同方式,并保持现有显示内容不变。
- Around line 666-674: 更新版本降级判断逻辑及其模块导入:将 packaging.version 提升为顶层依赖并显式处理
ImportError,缺少 packaging 时不要回退到字符串比较;仅在版本解析抛出版本格式异常时返回 False,确保合法版本使用
version.parse 比较,非法版本不判定为降级。
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 420eeac6-008d-4f0c-b314-e38448663834
📒 Files selected for processing (4)
docs/develop/guides/application_plugin_guide.mdsrc/one_dragon/base/operation/application/plugin_import_service.pysrc/zzz_od/gui/view/setting/app_setting_interface.pysrc/zzz_od/gui/view/setting/setting_plugin_interface.py
🚧 Files skipped from review as they are similar to previous changes (1)
- src/zzz_od/gui/view/setting/app_setting_interface.py
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/one_dragon/base/operation/application/application_factory_manager.py`:
- Around line 145-156: 在 discover_factories() 使用的目录扫描流程中,更新 _scan_directory()
及其根目录解析、plugin_dir 枚举和 python_file 枚举逻辑,捕获预期的 OSError 与
RuntimeError。记录对应目录扫描失败后继续处理其他目录或插件,避免 resolve(strict=True)、iterdir() 或 glob()
的异常中断全部插件发现。
- Around line 142-147: 更新 _scan_directory() 中 source == PluginSource.THIRD_PARTY
的扫描逻辑:对 plugins 目录下每个真实插件根目录仅检查第一层的 *_factory.py 文件,避免使用会递归发现嵌套 factory 的
directory.rglob。保持内置应用路径的递归扫描行为不变,并继续处理目录不存在或不可用的情况。
- Around line 147-155: 在插件扫描循环中更新 application factory manager 的目录遍历逻辑:不要直接使用无序的
directory.iterdir(),先按稳定键对插件目录排序,再依序执行现有的符号链接、目录有效性、解析路径和 APP_ID 注册流程。保持重复
APP_ID 时首次注册插件保留的行为不变。
In `@src/one_dragon/base/operation/application/plugin_import_service.py`:
- Around line 539-559: Expand the exception handling in
import_plugins_from_directory and import_directory to catch filesystem and other
directory-analysis failures, including OSError/PermissionError, instead of only
ValueError. Return the existing failed ImportResult response with the exception
message, matching the broader handling used by preview_plugins_from_directory.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: b3cb7198-2eae-41be-90f5-9cb547f038cf
📒 Files selected for processing (6)
docs/develop/guides/application_plugin_guide.mddocs/develop/one_dragon/modules/application_plugin_system.mdplugins/README.mdsrc/one_dragon/base/operation/application/application_factory_manager.pysrc/one_dragon/base/operation/application/plugin_import_service.pysrc/zzz_od/gui/view/setting/setting_plugin_interface.py
| # 内置应用递归扫描;第三方插件只扫描每个真实插件根的第一层。 | ||
| if source == PluginSource.THIRD_PARTY: | ||
| python_files: list[Path] = [] | ||
| if directory.is_dir(): | ||
| directory_resolved = directory.resolve(strict=True) | ||
| for plugin_dir in directory.iterdir(): |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
ast-grep outline \
src/zzz_od/application/devtools/application_priority_scanner.py \
--items all \
--type function
rg -n -C 8 \
'\bApplicationPriorityScanner\b|\b_scan_directory\s*\(|PluginSource\.(BUILTIN|THIRD_PARTY)' \
src/zzz_od src/one_dragonRepository: OneDragon-Anything/ZenlessZoneZero-OneDragon
Length of output: 22112
🏁 Script executed:
#!/bin/bash
set -euo pipefail
cat -n src/zzz_od/application/devtools/application_priority_scanner.py | sed -n '1,135p'
printf '\n--- manager scan implementation ---\n'
cat -n src/one_dragon/base/operation/application/application_factory_manager.py | sed -n '123,214p'
printf '\n--- scanner consumers and generated priority fields ---\n'
rg -n -C 6 \
'scan_application_priorities|ApplicationPriorityItem|app_priority|priority' \
src/zzz_od/application src/one_dragon/base/operation/application \
-g '*.py' | head -n 260Repository: OneDragon-Anything/ZenlessZoneZero-OneDragon
Length of output: 36449
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import ast
from pathlib import Path
scanner = Path("src/zzz_od/application/devtools/application_priority_scanner.py")
tree = ast.parse(scanner.read_text(encoding="utf-8"), filename=str(scanner))
scan_fn = next(
node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "_scan_directory"
)
rglob_calls = [
ast.unparse(node)
for node in ast.walk(scan_fn)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "rglob"
]
scan_app_fn = next(
node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "scan_application_priorities"
)
third_party_calls = [
ast.unparse(node)
for node in ast.walk(scan_app_fn)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "_scan_directory"
and len(node.args) >= 2
and isinstance(node.args[1], ast.Constant)
and node.args[1].value == "third_party"
]
print({"_scan_directory_rglob_calls": rglob_calls})
print({"third_party_scan_calls": third_party_calls})
assert rglob_calls == ["directory.rglob('*_factory.py')"]
assert third_party_calls
PYRepository: OneDragon-Anything/ZenlessZoneZero-OneDragon
Length of output: 371
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import ast
from pathlib import Path
scanner = Path("src/zzz_od/application/devtools/application_priority_scanner.py")
tree = ast.parse(scanner.read_text(encoding="utf-8"), filename=str(scanner))
scan_app_fn = next(
node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "scan_application_priorities"
)
append_calls = [
ast.unparse(node)
for node in ast.walk(scan_app_fn)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "append"
]
scan_calls = [
ast.unparse(node)
for node in ast.walk(scan_app_fn)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "_scan_directory"
]
print({"scan_directory_calls": scan_calls})
print({"scan_dir_appends": append_calls})
assert any("'third_party'" in call for call in append_calls)
assert any("source" in call for call in scan_calls)
PYRepository: OneDragon-Anything/ZenlessZoneZero-OneDragon
Length of output: 334
同步第三方插件的扫描边界。
scan_application_priorities() 会将 plugins 目录作为 third_party 传入 _scan_directory()。该方法仍执行 directory.rglob('*_factory.py'),会报告运行时不会注册的嵌套 factory。
当 source == 'third_party' 时,只扫描每个插件根目录的第一层。内置目录继续使用递归扫描。
🧰 Tools
🪛 Ruff (0.16.1)
[warning] 142-142: Comment contains ambiguous ; (FULLWIDTH SEMICOLON). Did you mean ; (SEMICOLON)?
(RUF003)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/one_dragon/base/operation/application/application_factory_manager.py`
around lines 142 - 147, 更新 _scan_directory() 中 source ==
PluginSource.THIRD_PARTY 的扫描逻辑:对 plugins 目录下每个真实插件根目录仅检查第一层的 *_factory.py
文件,避免使用会递归发现嵌套 factory 的 directory.rglob。保持内置应用路径的递归扫描行为不变,并继续处理目录不存在或不可用的情况。
| if directory.is_dir(): | ||
| directory_resolved = directory.resolve(strict=True) | ||
| for plugin_dir in directory.iterdir(): | ||
| if plugin_dir.is_symlink() or not plugin_dir.is_dir(): | ||
| continue | ||
| try: | ||
| plugin_dir_resolved = plugin_dir.resolve(strict=True) | ||
| except (OSError, RuntimeError): | ||
| continue | ||
| if plugin_dir_resolved != directory_resolved / plugin_dir.name: | ||
| continue | ||
| for python_file in plugin_dir.glob("*.py"): |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
隔离插件目录扫描中的文件系统异常。
discover_factories() 只在调用前检查 plugin_dir.is_dir()。目录在检查后被删除、替换,或路径解析遇到 OSError、RuntimeError 时,resolve(strict=True)、iterdir() 或 glob() 会直接抛出。
这些异常会越过 _scan_directory() 的工厂级异常处理,并中断全部插件扫描。请捕获根目录解析、目录枚举和单个插件文件枚举中的预期文件系统异常。记录失败后继续扫描其他插件。
建议的异常隔离方式
if source == PluginSource.THIRD_PARTY:
python_files: list[Path] = []
if directory.is_dir():
- directory_resolved = directory.resolve(strict=True)
- for plugin_dir in directory.iterdir():
+ try:
+ directory_resolved = directory.resolve(strict=True)
+ plugin_dirs = list(directory.iterdir())
+ except (FileNotFoundError, NotADirectoryError):
+ return non_default_factories, default_factories
+ except (OSError, RuntimeError) as exc:
+ self._scan_failures.append((directory, f"{type(exc).__name__}: {exc}"))
+ return non_default_factories, default_factories
+
+ for plugin_dir in plugin_dirs:
if plugin_dir.is_symlink() or not plugin_dir.is_dir():
continue
...
- for python_file in plugin_dir.glob("*.py"):
+ try:
+ python_files_in_plugin = list(plugin_dir.glob("*.py"))
+ except (OSError, RuntimeError) as exc:
+ self._scan_failures.append((plugin_dir, f"{type(exc).__name__}: {exc}"))
+ continue
+ for python_file in python_files_in_plugin:🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/one_dragon/base/operation/application/application_factory_manager.py`
around lines 145 - 156, 在 discover_factories() 使用的目录扫描流程中,更新 _scan_directory()
及其根目录解析、plugin_dir 枚举和 python_file 枚举逻辑,捕获预期的 OSError 与
RuntimeError。记录对应目录扫描失败后继续处理其他目录或插件,避免 resolve(strict=True)、iterdir() 或 glob()
的异常中断全部插件发现。
| for plugin_dir in directory.iterdir(): | ||
| if plugin_dir.is_symlink() or not plugin_dir.is_dir(): | ||
| continue | ||
| try: | ||
| plugin_dir_resolved = plugin_dir.resolve(strict=True) | ||
| except (OSError, RuntimeError): | ||
| continue | ||
| if plugin_dir_resolved != directory_resolved / plugin_dir.name: | ||
| continue |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
curl -fsSL 'https://docs.python.org/3.11/library/pathlib.html' \
| grep -F 'The children are yielded in arbitrary order'
rg -n -C 4 'iterdir\(\)|重复的 APP_ID|先注册者胜' \
src/one_dragon/base/operation/application/application_factory_manager.py \
docs/develop/one_dragon/modules/application_plugin_system.mdRepository: OneDragon-Anything/ZenlessZoneZero-OneDragon
Length of output: 4444
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- candidate structure ---'
ast-grep outline src/one_dragon/base/operation/application/application_factory_manager.py
printf '%s\n' '--- scan and registration symbols ---'
rg -n -C 8 \
'def (scan|_scan|_register_plugin_metadata|_read_plugin_metadata)|_register_plugin_metadata\(|_read_plugin_metadata\(|_plugin_infos|scan_failures' \
src/one_dragon/base/operation/application/application_factory_manager.py
printf '%s\n' '--- relevant source sections ---'
sed -n '110,190p' src/one_dragon/base/operation/application/application_factory_manager.py
sed -n '360,455p' src/one_dragon/base/operation/application/application_factory_manager.pyRepository: OneDragon-Anything/ZenlessZoneZero-OneDragon
Length of output: 14038
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from dataclasses import dataclass
`@dataclass`(frozen=True)
class Plugin:
directory: str
app_id: str
def scan(order: list[Plugin]) -> tuple[str, list[str]]:
registered: dict[str, Plugin] = {}
failures: list[str] = []
for plugin in order:
if plugin.app_id in registered:
failures.append(plugin.directory)
continue
registered[plugin.app_id] = plugin
winner = registered["same"].directory
return winner, failures
plugins = [
Plugin("Zoo", "same"),
Plugin("alpha", "same"),
]
sorted_plugins = sorted(plugins, key=lambda plugin: (plugin.directory.casefold(), plugin.directory))
assert scan(plugins) == ("Zoo", ["alpha"])
assert scan(list(reversed(plugins))) == ("alpha", ["Zoo"])
assert scan(sorted_plugins) == ("alpha", ["Zoo"])
print("unsorted order 1:", scan(plugins))
print("unsorted order 2:", scan(list(reversed(plugins))))
print("proposed sorted order:", [plugin.directory for plugin in sorted_plugins])
print("proposed sorted result:", scan(sorted_plugins))
PYRepository: OneDragon-Anything/ZenlessZoneZero-OneDragon
Length of output: 339
稳定第三方插件的加载顺序。
Path.iterdir() 按任意顺序返回目录项。重复 APP_ID 时,首次注册的插件保留,后续插件加载失败。因此插件选择可能随文件系统顺序变化。按稳定键排序插件目录后再扫描。
建议的排序方式
- for plugin_dir in directory.iterdir():
+ for plugin_dir in sorted(
+ directory.iterdir(),
+ key=lambda path: (path.name.casefold(), path.name),
+ ):📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| for plugin_dir in directory.iterdir(): | |
| if plugin_dir.is_symlink() or not plugin_dir.is_dir(): | |
| continue | |
| try: | |
| plugin_dir_resolved = plugin_dir.resolve(strict=True) | |
| except (OSError, RuntimeError): | |
| continue | |
| if plugin_dir_resolved != directory_resolved / plugin_dir.name: | |
| continue | |
| for plugin_dir in sorted( | |
| directory.iterdir(), | |
| key=lambda path: (path.name.casefold(), path.name), | |
| ): | |
| if plugin_dir.is_symlink() or not plugin_dir.is_dir(): | |
| continue | |
| try: | |
| plugin_dir_resolved = plugin_dir.resolve(strict=True) | |
| except (OSError, RuntimeError): | |
| continue | |
| if plugin_dir_resolved != directory_resolved / plugin_dir.name: | |
| continue |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/one_dragon/base/operation/application/application_factory_manager.py`
around lines 147 - 155, 在插件扫描循环中更新 application factory manager 的目录遍历逻辑:不要直接使用无序的
directory.iterdir(),先按稳定键对插件目录排序,再依序执行现有的符号链接、目录有效性、解析路径和 APP_ID 注册流程。保持重复
APP_ID 时首次注册插件保留的行为不变。
| try: | ||
| layouts = self._analyze_directory(dir_path) | ||
| if selected_plugin_names is not None: | ||
| requested_names = {name.casefold() for name in selected_plugin_names} | ||
| available_names = {layout.plugin_dir_name.casefold() for layout in layouts} | ||
| missing_names = sorted(requested_names - available_names) | ||
| if missing_names: | ||
| return [ | ||
| ImportResult( | ||
| success=False, | ||
| plugin_name=source_name, | ||
| message=f"目录中不存在指定插件: {', '.join(missing_names)}", | ||
| ) | ||
| ] | ||
| layouts = [ | ||
| layout | ||
| for layout in layouts | ||
| if layout.plugin_dir_name.casefold() in requested_names | ||
| ] | ||
| except ValueError as e: | ||
| return [ImportResult(success=False, plugin_name=source_name, message=str(e))] |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
import_plugins_from_directory 的异常捕获过窄,OSError 会传播到 Qt 槽函数。
_analyze_directory 会经由 _collect_directory_file_paths 和 _validate_directory_symlinks 触发文件系统调用。这些调用在权限不足、路径过长或遍历期间目录被删除时抛出 OSError/PermissionError,而不是 ValueError。这里只捕获 ValueError,异常会向上传播。
调用方 src/zzz_od/gui/view/setting/setting_plugin_interface.py 的 _on_import_dir_clicked(Line 494)没有 try 包裹。PySide6 6.5+ 默认在槽函数中出现未处理异常时终止进程。
同一文件中 preview_plugins_from_directory(Line 664)使用 except Exception,两条路径的容错级别不一致。
🛠️ 建议修改
- except ValueError as e:
+ except (ValueError, OSError) as e:
return [ImportResult(success=False, plugin_name=source_name, message=str(e))]import_directory(Line 514)同样只捕获 ValueError,建议一并调整。
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try: | |
| layouts = self._analyze_directory(dir_path) | |
| if selected_plugin_names is not None: | |
| requested_names = {name.casefold() for name in selected_plugin_names} | |
| available_names = {layout.plugin_dir_name.casefold() for layout in layouts} | |
| missing_names = sorted(requested_names - available_names) | |
| if missing_names: | |
| return [ | |
| ImportResult( | |
| success=False, | |
| plugin_name=source_name, | |
| message=f"目录中不存在指定插件: {', '.join(missing_names)}", | |
| ) | |
| ] | |
| layouts = [ | |
| layout | |
| for layout in layouts | |
| if layout.plugin_dir_name.casefold() in requested_names | |
| ] | |
| except ValueError as e: | |
| return [ImportResult(success=False, plugin_name=source_name, message=str(e))] | |
| try: | |
| layouts = self._analyze_directory(dir_path) | |
| if selected_plugin_names is not None: | |
| requested_names = {name.casefold() for name in selected_plugin_names} | |
| available_names = {layout.plugin_dir_name.casefold() for layout in layouts} | |
| missing_names = sorted(requested_names - available_names) | |
| if missing_names: | |
| return [ | |
| ImportResult( | |
| success=False, | |
| plugin_name=source_name, | |
| message=f"目录中不存在指定插件: {', '.join(missing_names)}", | |
| ) | |
| ] | |
| layouts = [ | |
| layout | |
| for layout in layouts | |
| if layout.plugin_dir_name.casefold() in requested_names | |
| ] | |
| except (ValueError, OSError) as e: | |
| return [ImportResult(success=False, plugin_name=source_name, message=str(e))] |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/one_dragon/base/operation/application/plugin_import_service.py` around
lines 539 - 559, Expand the exception handling in import_plugins_from_directory
and import_directory to catch filesystem and other directory-analysis failures,
including OSError/PermissionError, instead of only ValueError. Return the
existing failed ImportResult response with the exception message, matching the
broader handling used by preview_plugins_from_directory.
Summary by CodeRabbit