Skip to content
Open
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
85 changes: 79 additions & 6 deletions docs/develop/guides/application_plugin_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,21 +118,94 @@ class MyPluginFactory(ApplicationFactory):
## 通过 GUI 导入插件

1. 打开设置 → 插件管理
2. 点击"导入插件"按钮
3. 选择 `.zip` 格式的插件压缩包
4. 插件自动解压到 `plugins/` 并注册
2. 点击“导入 ZIP”或“导入目录”
3. 选择插件压缩包、单个插件目录或包含多个插件的集合目录
4. 插件分别安装到 `plugins/<插件名>/` 并注册

### zip 包结构
### ZIP 包结构

一个可安装插件包必须在插件根目录直接放置一个主 `*_factory.py` 和一个 `*_const.py`:

```
my_plugin.zip
└── my_plugin/
└── my_plugin/ # 插件根目录
├── __init__.py
├── my_plugin_const.py
├── my_plugin_factory.py # 唯一 factory
├── my_plugin.py
├── src/
│ └── ...
└── assets/
└── ...
```

ZIP 外层可以有仓库目录、发布目录或说明文件。导入器会把主 factory 所在目录作为插件根,只导入该目录的子树:

```
repository-main/
├── README.md # 不导入
├── registry.json # 不导入
└── my_plugin/ # 从这里开始导入
├── my_plugin_const.py
├── my_plugin_factory.py
└── my_plugin.py
└── operations/
```

第三方插件只注册插件根第一层的主 factory。插件根子目录可以放置普通 Python 模块和资源,但其中的 `*_factory.py` 不会被自动发现或注册。

一个 ZIP 可以包含多个互不隶属的合法插件根。插件管理界面会把它视为插件集合,分别预览、安装、覆盖和报告结果:

```
plugin_bundle.zip
└── repository-main/
├── README.md # 不安装
├── plugin_a/ # 安装到 plugins/plugin_a/
│ ├── plugin_a_factory.py
│ └── plugin_a_const.py
└── plugin_b/ # 安装到 plugins/plugin_b/
├── plugin_b_factory.py
└── plugin_b_const.py
```

如果 ZIP 根本身已经是合法插件根,整个 ZIP 只作为一个插件,根内更深的 factory 不会再拆成其他插件。集合中大小写不敏感的插件目录名重复时,整个来源会在写盘前被拒绝。

### 插件根和运行文件边界

主 factory 所在目录就是可安装插件根。导入器只安装该目录及其子树,插件运行所需的代码、资源和配置必须全部放在这个范围内。当前导入器不读取 `plugin.json` 来声明插件根、源码根或额外安装目录。

可以在插件根内使用 `src/`、`assets/` 等目录:

```
my_plugin/ # 插件根
├── my_plugin_factory.py # 主 factory
├── my_plugin_const.py
├── src/
│ └── ...
└── assets/
└── ...
```

不能把运行资源放在主 factory 目录之外:

```
repository/
├── src/
│ └── my_plugin/
│ ├── my_plugin_factory.py # 导入器会把这里识别为插件根
│ └── my_plugin_const.py
└── assets/ # 位于插件根外,不会安装
```

源码仓库可以自由使用 `src-layout`、测试目录和构建脚本,但提供给插件管理器的 ZIP 必须包含一个自包含的运行包。可以在发布时整理成上面的 `my_plugin/` 结构,也可以在源码仓库中直接维护一个独立的运行包目录。

插件内的 Python 模块可以使用相对导入引用插件根内的代码;不能越过插件根依赖外部代码或文件。导入器不会修改原 ZIP,但插件根外的内容不会复制到 `plugins/`。

导入目录时,如果所选目录本身是合法插件根,就按单个插件处理;否则向下查找多个互不隶属的最外层合法插件根。集合目录本身和各插件根之外的文件不会复制到 `plugins/`。目录中的符号链接不能指向所属插件根之外,也不能形成复制循环;插件根第一层的 Python 文件不能是符号链接。

导入器会拒绝绝对路径、上级路径、重复落盘路径和文件/目录冲突;`__MACOSX`、`.DS_Store` 等系统元数据不会参与结构判断。一个 ZIP 中所有本次选中插件根的解压后总体积不能超过 512 MiB。每个插件独立使用临时目录和替换回滚,一个插件失败不会撤销同一来源中其他已成功插件;覆盖重试也只处理用户确认的插件。

插件管理、覆盖和删除都以 `plugins/<插件目录>/` 为单位,不要求插件目录名与 `APP_ID` 相同,但插件目录名必须唯一。

---

## 运行时刷新
Expand Down
27 changes: 21 additions & 6 deletions docs/develop/one_dragon/modules/application_plugin_system.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,11 @@ project_root/
└── my_plugin/
├── __init__.py
├── my_plugin_const.py
├── my_plugin_factory.py
├── my_plugin_factory.py # 只扫描插件根第一层
├── my_plugin.py
└── sub/
├── __init__.py
├── sub_feature_const.py
└── sub_feature_factory.py
└── helper.py # 普通子模块,不扫描子目录 factory
```

## 应用分组
Expand Down Expand Up @@ -141,13 +140,15 @@ src/zzz_od/application/my_app/my_app_factory.py

### 第三方插件 (THIRD_PARTY)

将 `plugins/` 目录加入 `sys.path`,模块名从 plugins 目录开始计算。
支持嵌套子目录,中间包会自动加载或创建为命名空间包:
将 `plugins/` 目录加入 `sys.path`,模块名从 plugins 目录开始计算。第三方插件只发现 `plugins/<插件根>/*_factory.py`;插件根内的嵌套子目录仍可作为普通 Python 子包导入,但其中的 factory 不会注册。

```
plugins/my_plugin/my_plugin_factory.py
→ module_root: plugins/
→ 模块名: my_plugin.my_plugin_factory

plugins/my_plugin/sub/helper.py
→ 可由 my_plugin 内的代码导入,不参与 factory 发现
```

### 中间包加载
Expand All @@ -169,6 +170,19 @@ plugins/my_plugin/my_plugin_factory.py
- `plugins/` 目录仅添加一次到 sys.path
- 使用集合跟踪已添加的路径,避免重复

## 插件导入边界

`PluginImportService` 把 ZIP 或松散目录视为一个“来源”。来源根直接包含唯一 `*_factory.py` 和唯一 `*_const.py` 时,整个来源是一个插件;否则识别多个互不隶属的最外层合法插件根。

- 每个插件根分别映射到 `plugins/<插件名>/`,来源包装目录和根外文件不安装。
- 插件根内的深层 factory 不参与根识别,也不会被第三方运行时扫描。
- 同一来源中大小写不敏感的重复插件目录名会在写盘前拒绝。
- ZIP 中本次选中插件根的解压后总体积受 512 MiB 上限约束。
- 松散目录中的符号链接不能越出所属插件根或形成复制循环;插件根第一层的 Python 文件不能是符号链接。
- 每个插件独立使用临时目录、覆盖和失败回滚;覆盖重试按“来源路径 + 插件名”选择,不重复处理同来源中已成功的插件。

旧的 `preview_plugin()`、`import_plugin()`、`preview_directory()`、`import_directory()` 保持单插件语义;插件管理界面使用一对多来源接口。

## 插件生命周期流程

```
Expand All @@ -183,7 +197,8 @@ OneDragonContext.init()
│ │ │ └── (plugins, THIRD_PARTY)
│ │ │
│ │ ├── _scan_directory() ─── 对每个目录
│ │ │ ├── rglob("*.py") ─── 收集 *_factory.py / *_const.py
│ │ │ ├── BUILTIN: rglob("*.py")
│ │ │ ├── THIRD_PARTY: 枚举真实插件根后 glob("*.py"),跳过符号链接目录和文件
│ │ │ ├── 冲突检测 ─── 同目录多个 factory/const → 跳过 + 记录
│ │ │ └── _load_factory_from_file() ─── 对每个 factory 文件
│ │ │ ├── resolve_module_name() ─── 计算 dotted name + module_root
Expand Down
71 changes: 60 additions & 11 deletions plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,67 @@
## 加载机制

加载插件时,`plugins/` 目录会被添加到 `sys.path`,使每个插件包成为独立的顶级模块。
支持嵌套子目录,所有中间包会自动加载(有 `__init__.py` 时执行它,没有时创建命名空间包)
插件根内支持嵌套子目录和 Python 子包,但第三方插件只扫描插件根第一层的 `*_factory.py`:

```python
# 加载过程
sys.path.insert(0, "project_root/plugins") # 添加一次

# 插件模块名示例
# plugins/my_plugin/my_plugin_factory.py → my_plugin.my_plugin_factory
# plugins/my_plugin/sub/sub_factory.py → my_plugin.sub.sub_factory
# plugins/my_plugin/my_plugin_factory.py → 会注册
# plugins/my_plugin/sub/helper.py → 可以被插件代码导入
# plugins/my_plugin/sub/sub_factory.py → 不会被自动发现或注册
```

## 插件根和运行文件边界

每个 `plugins/<插件名>/` 目录都是一个独立的插件根,主 `*_factory.py` 和对应的 `*_const.py` 必须直接放在插件根中。插件运行所需的代码、资源和配置必须全部位于插件根子树内。

可以在插件根内使用 `src/`、`assets/` 等目录:

```
plugins/
└── my_plugin/ # 插件根
├── my_plugin_factory.py
├── my_plugin_const.py
├── src/
│ └── ...
└── assets/
└── ...
```

不支持把 factory 放在 `src/` 深处,同时依赖 factory 目录之外的兄弟目录:

```
repository/
├── src/my_plugin/
│ ├── my_plugin_factory.py # 这里会被识别为插件根
│ └── my_plugin_const.py
└── assets/ # 插件根外,导入时不会安装
```

源码仓库可以使用任意开发布局,但交给插件管理器导入的 ZIP 必须包含一个自包含的运行包。当前导入器不读取 `plugin.json` 来声明插件根、源码根或额外安装目录。

插件内的 Python 模块可以用相对导入引用插件根内的代码,但不能越过插件根依赖外部文件。ZIP 外层的仓库目录、开发脚本和说明文件不会复制到 `plugins/`;原 ZIP 本身不会被修改。

## 集合 ZIP 和集合目录

一个 ZIP 或所选目录可以包含多个互不隶属的合法插件根。插件管理器只选择最外层合法根,并分别安装为 `plugins/<插件名>/`:

```
plugin_collection/
├── README.md # 不安装
├── plugin_a/
│ ├── plugin_a_factory.py
│ └── plugin_a_const.py
└── plugin_b/
├── plugin_b_factory.py
└── plugin_b_const.py
```

如果来源根本身直接包含唯一 `*_factory.py` 和唯一 `*_const.py`,整个来源只作为一个插件,不再拆分其子目录。插件根内更深的 factory 仍是普通文件,不会成为第二个插件,也不会被运行时注册。

同一来源中插件目录名大小写不敏感地重复时,整个来源会在写盘前被拒绝。每个插件独立安装、覆盖和回滚;覆盖已存在插件时,不会重复处理同一来源中已经成功安装的其他插件。松散目录中的符号链接不能越出所属插件根或形成循环,插件根第一层的 Python 文件不能是符号链接。

## 目录结构示例

```
Expand All @@ -35,14 +85,13 @@ plugins/ # ← 添加到 sys.path
│ └── utils/ # 子包
│ ├── __init__.py
│ └── helper.py
├── plugin_b/ # 插件 B(含嵌套 factory
├── plugin_b/ # 插件 B(含子包
│ ├── __init__.py
│ ├── plugin_b_const.py # 主插件常量
│ ├── plugin_b_factory.py # 主插件工厂
│ └── sub_feature/ # 子功能模块
│ ├── plugin_b_const.py
│ ├── plugin_b_factory.py # 插件根第一层的唯一工厂
│ └── sub_feature/ # 普通子包
│ ├── __init__.py
│ ├── sub_feature_const.py
│ └── sub_feature_factory.py # 嵌套工厂,模块名: plugin_b.sub_feature.sub_feature_factory
│ └── helper.py
└── plugin_c/ # 插件 C
├── __init__.py
├── plugin_c_const.py
Expand Down Expand Up @@ -130,4 +179,4 @@ class MyPlugin(Application):
4. **模块名唯一性**:插件包名(目录名)应该唯一,避免与其他插件或主程序模块冲突
5. **备份**:此目录被 `.gitignore` 忽略,请自行备份
6. **热重载**:刷新应用时会卸载整个插件包并重新加载
7. **嵌套目录**:支持在插件包内任意深度放置 `_factory.py` 文件
7. **factory 层级**:第三方插件只注册插件根第一层的唯一 `_factory.py`,子目录中的 `_factory.py` 不会被扫描
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,35 @@ def _scan_directory(
non_default_factories: list[ApplicationFactory] = []
default_factories: list[ApplicationFactory] = []

# 一次性扫描所有 .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():
Comment on lines +142 to +147

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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_dragon

Repository: 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 260

Repository: 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
PY

Repository: 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)
PY

Repository: 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 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
Comment on lines +147 to +155

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.md

Repository: 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.py

Repository: 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))
PY

Repository: 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.

Suggested change
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 时首次注册插件保留的行为不变。

for python_file in plugin_dir.glob("*.py"):
Comment on lines +145 to +156

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

隔离插件目录扫描中的文件系统异常。

discover_factories() 只在调用前检查 plugin_dir.is_dir()。目录在检查后被删除、替换,或路径解析遇到 OSErrorRuntimeError 时,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()
的异常中断全部插件发现。

if python_file.is_symlink():
continue
try:
python_file_resolved = python_file.resolve(strict=True)
except (OSError, RuntimeError):
continue
if python_file_resolved != plugin_dir_resolved / python_file.name:
continue
python_files.append(python_file)
else:
python_files = list(directory.rglob("*.py"))
factory_files: list[Path] = []
const_files: list[Path] = []
for f in directory.rglob("*.py"):
for f in python_files:
if f.stem.endswith(self._factory_module_suffix):
factory_files.append(f)
elif f.stem.endswith(self._const_module_suffix):
Expand Down
Loading
Loading