Skip to content

Commit e611fd8

Browse files
committed
引入插件依赖机制,插件需要依赖时,可以在插件目录下添加requirements.txt
插件市场小型插件投稿可以在path中填写依赖文件 例如:"path": ["alone/字符字.py", "alone/requirements.txt"]
1 parent 8d8e8b5 commit e611fd8

4 files changed

Lines changed: 266 additions & 49 deletions

File tree

core/base/pip_helper.py

Lines changed: 98 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
"""
1010

1111
import asyncio
12+
import contextlib
1213
import importlib
1314
import importlib.metadata as _metadata
1415
import os
1516
import re
1617
import subprocess
1718
import sys
19+
import tempfile
1820
import threading
1921

2022
from core.base.config import cfg
@@ -73,24 +75,83 @@ def _pip_install_sync(cmd, name, deps_summary=''):
7375
return result.returncode, stderr
7476

7577

76-
def all_requirements_met(req_path):
77-
"""检查 requirements.txt 中所有包是否已安装 (纯本地, 不联网)。"""
78+
_REQ_RE = re.compile(r'^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(.*)$') # 包名 + 可选 extras + 其余
79+
80+
81+
def _norm_pkg(name):
82+
"""PEP 503 规范化包名 (跨清单去重用)。"""
83+
return re.sub(r'[-_.]+', '-', name).lower()
84+
85+
86+
def _parse_req(line):
87+
"""解析一行依赖 → (包名, 版本元组, 原始 spec); 无效行返回 None。"""
88+
s = line.split('#', 1)[0].strip()
89+
if not s or s.startswith('-'):
90+
return None
91+
m = _REQ_RE.match(s)
92+
if not m:
93+
return None
94+
name, rest = m.group(1), m.group(2).split(';', 1)[0]
95+
vm = re.search(r'([0-9]+(?:\.[0-9]+)*)', rest)
96+
try:
97+
ver = tuple(int(x) for x in vm.group(1).split('.')) if vm else ()
98+
except ValueError:
99+
ver = ()
100+
return name, ver, s
101+
102+
103+
def _merge_requirements(req_files):
104+
"""合并多份依赖清单, 同包版本冲突取最高版本, 保持首次出现顺序。"""
105+
best, order = {}, []
106+
for fp in req_files:
107+
try:
108+
with open(fp, encoding='utf-8') as f:
109+
lines = f.readlines()
110+
except Exception:
111+
continue
112+
for raw in lines:
113+
parsed = _parse_req(raw)
114+
if not parsed:
115+
continue
116+
name, ver, spec = parsed
117+
key = _norm_pkg(name)
118+
if key not in best:
119+
best[key] = (ver, spec)
120+
order.append(key)
121+
elif ver > best[key][0]:
122+
best[key] = (ver, spec)
123+
return [best[k][1] for k in order]
124+
125+
126+
def _discover_req_files(target_dir):
127+
"""目录下所有依赖清单: requirements.txt + *_requirements.txt。"""
78128
try:
79-
with open(req_path, encoding='utf-8') as f:
80-
lines = f.readlines()
81-
except Exception:
82-
return False
83-
for raw in lines:
84-
pkg = re.split(r'[>=<!\[;]', raw.strip())[0].strip()
85-
if not pkg or pkg.startswith(('#', '-')):
129+
names = sorted(os.listdir(target_dir))
130+
except OSError:
131+
return []
132+
return [os.path.join(target_dir, f) for f in names
133+
if (f == 'requirements.txt' or f.endswith('_requirements.txt'))
134+
and os.path.isfile(os.path.join(target_dir, f))]
135+
136+
137+
def _specs_met(specs):
138+
"""检查依赖 spec 是否都已安装 (纯本地)。"""
139+
for spec in specs:
140+
parsed = _parse_req(spec)
141+
if not parsed:
86142
continue
87143
try:
88-
_metadata.distribution(pkg)
144+
_metadata.distribution(parsed[0])
89145
except _metadata.PackageNotFoundError:
90146
return False
91147
return True
92148

93149

150+
def all_requirements_met(req_path):
151+
"""检查单份 requirements.txt 中所有包是否已安装 (纯本地)。"""
152+
return _specs_met(_merge_requirements([req_path]))
153+
154+
94155
def _mirror_attempts():
95156
"""返回按优先级排列的镜像参数列表; '' 表示官方源 (不带 -i)。
96157
@@ -128,27 +189,25 @@ def _build_cmd(req_path, mirror, no_cache, target=None):
128189

129190

130191
async def install_requirements(name, target_dir, *, skip_if_met=False, no_cache=False):
131-
"""检查并安装 requirements.txt 依赖 (venv 原生, 无 root 时 --user 兜底, 多镜像兜底)。
132-
133-
全程在独立线程池里跑, 不阻塞事件循环。
134-
"""
135-
req_path = os.path.join(target_dir, 'requirements.txt')
136-
if not os.path.isfile(req_path):
137-
return
192+
"""安装目录下所有依赖清单 (合并后装齐, venv原生/.pydeps免root/多镜像兜底, 独立线程池不阻塞)。"""
138193
if not cfg.get('settings', 'pip.auto_install', True):
139194
return
140-
if skip_if_met and all_requirements_met(req_path):
195+
req_files = _discover_req_files(target_dir)
196+
if not req_files:
141197
return
142-
143-
try:
144-
with open(req_path, encoding='utf-8') as f:
145-
deps = [line.strip() for line in f if line.strip() and not line.startswith(('#', '-'))]
146-
deps_summary = ', '.join(deps)
147-
except Exception:
148-
deps_summary = ''
198+
specs = _merge_requirements(req_files)
199+
if not specs:
200+
return
201+
if skip_if_met and _specs_met(specs):
202+
return
203+
deps_summary = ', '.join(specs)
149204

150205
loop = asyncio.get_running_loop()
151206
mirrors = _mirror_attempts()
207+
# 合并结果写临时清单交给 pip, 不污染插件目录
208+
fd, req_path = tempfile.mkstemp(prefix='elaina-req-', suffix='.txt')
209+
with os.fdopen(fd, 'w', encoding='utf-8') as f:
210+
f.write('\n'.join(specs) + '\n')
152211

153212
async def _try(target=None):
154213
"""按镜像顺序尝试安装; 返回 (成功?, 最后错误, 是否疑似权限问题)。"""
@@ -172,16 +231,19 @@ async def _try(target=None):
172231
log.warning(f'[{name}] pip 失败 (源: {tag}): {stderr[:200]}')
173232
return False, last, perm
174233

175-
ok, last_stderr, perm = await _try()
176-
if ok:
177-
return
178-
# site-packages 不可写 (无 root / venv 属主为 root) → 改装到可写的 .pydeps 并注入 sys.path
179-
if perm:
180-
os.makedirs(_DEPS_DIR, exist_ok=True)
181-
_ensure_deps_dir_on_path()
182-
log.info(f'[{name}] site-packages 不可写, 依赖改装到 {_DEPS_DIR} (免 root)...')
183-
ok, last_stderr, _ = await _try(target=_DEPS_DIR)
234+
try:
235+
ok, last_stderr, perm = await _try()
184236
if ok:
185237
return
186-
187-
log.warning(f'[{name}] 依赖安装失败, 已尝试全部镜像/兜底; 最后错误: {last_stderr[:200]}')
238+
# site-packages 不可写 (无 root / venv 属主为 root) → 改装到可写的 .pydeps 并注入 sys.path
239+
if perm:
240+
os.makedirs(_DEPS_DIR, exist_ok=True)
241+
_ensure_deps_dir_on_path()
242+
log.info(f'[{name}] site-packages 不可写, 依赖改装到 {_DEPS_DIR} (免 root)...')
243+
ok, last_stderr, _ = await _try(target=_DEPS_DIR)
244+
if ok:
245+
return
246+
log.warning(f'[{name}] 依赖安装失败, 已尝试全部镜像/兜底; 最后错误: {last_stderr[:200]}')
247+
finally:
248+
with contextlib.suppress(OSError):
249+
os.remove(req_path)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""单元测试: pip_helper 多依赖清单发现 + 合并 + 版本冲突取最高版本"""
2+
3+
import os
4+
import tempfile
5+
6+
from core.base import pip_helper
7+
8+
9+
def _write(d, name, text):
10+
with open(os.path.join(d, name), 'w', encoding='utf-8') as f:
11+
f.write(text)
12+
13+
14+
def test_parse_req():
15+
assert pip_helper._parse_req('openai>=1.0.0')[0] == 'openai'
16+
assert pip_helper._parse_req('openai>=1.0.0')[1] == (1, 0, 0)
17+
assert pip_helper._parse_req('# comment') is None
18+
assert pip_helper._parse_req('-r other.txt') is None
19+
assert pip_helper._parse_req(' ') is None
20+
# extras + marker 不影响包名
21+
assert pip_helper._parse_req('uvicorn[standard]>=0.30; python_version>="3.8"')[0] == 'uvicorn'
22+
23+
24+
def test_norm_pkg():
25+
assert pip_helper._norm_pkg('Nonebot_Plugin.Foo') == 'nonebot-plugin-foo'
26+
27+
28+
def test_discover_and_merge_highest_version():
29+
d = tempfile.mkdtemp()
30+
# 共享 alone 目录: 两个插件各一份, 都声明 openai 但版本不同
31+
_write(d, '今日猪猪_requirements.txt', 'openai>=1.0.0\nhttpx>=0.27\n')
32+
_write(d, '字符字_requirements.txt', 'openai>=1.3.0\nnumpy>=1.24\n')
33+
_write(d, 'requirements.txt', 'pillow>=10.0\n')
34+
# 非依赖清单不应被发现
35+
_write(d, 'config.txt', 'ignore me\n')
36+
37+
files = pip_helper._discover_req_files(d)
38+
names = {os.path.basename(f) for f in files}
39+
assert names == {'今日猪猪_requirements.txt', '字符字_requirements.txt', 'requirements.txt'}
40+
41+
merged = pip_helper._merge_requirements(files)
42+
# openai 取最高版本 1.3.0
43+
assert 'openai>=1.3.0' in merged
44+
assert 'openai>=1.0.0' not in merged
45+
# 其它包保留
46+
assert any(s.startswith('httpx') for s in merged)
47+
assert any(s.startswith('numpy') for s in merged)
48+
assert any(s.startswith('pillow') for s in merged)
49+
# openai 只出现一次
50+
assert sum(1 for s in merged if s.startswith('openai')) == 1

tests/test_market_multi_install.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,56 @@ async def fake_dl(url, **kw):
161161
assert os.path.isfile(os.path.join(pdir, 'index.py'))
162162
assert os.path.isfile(os.path.join(pdir, 'x.html'))
163163
assert not os.path.exists(os.path.join(pdir, 'index.py').replace('A', 'B'))
164+
165+
166+
# ==================== single(alone): path 声明 requirements.txt ====================
167+
168+
169+
async def test_alone_path_with_requirements(base, monkeypatch):
170+
"""path 为数组: .py 装到 alone/<名>.py, 声明的 requirements.txt → alone/<名>_requirements.txt"""
171+
async def fake_dl(url, **kw):
172+
if url.endswith('requirements.txt'):
173+
return b'openai>=1.0.0\n'
174+
return b'"""x"""\n__plugin_meta__ = {"version": "1.0.0"}\n'
175+
176+
monkeypatch.setattr(install, '_download_file', fake_dl)
177+
result, target = await install._install_single(
178+
'https://github.com/u/r', '今日猪猪',
179+
path=['alone/今日猪猪.py', 'alone/requirements.txt'], alone=True)
180+
assert result['success'], result
181+
assert target == install._ALONE_DIR
182+
alone = os.path.join(base, 'plugins', 'alone')
183+
assert os.path.isfile(os.path.join(alone, '今日猪猪.py'))
184+
# 默认名 requirements.txt 加前缀
185+
assert os.path.isfile(os.path.join(alone, '今日猪猪_requirements.txt'))
186+
assert not os.path.exists(os.path.join(alone, 'requirements.txt'))
187+
188+
189+
async def test_alone_path_named_requirements_kept(base, monkeypatch):
190+
"""作者声明的文件本就是 xxx_requirements.txt → 原样保存, 不加前缀"""
191+
async def fake_dl(url, **kw):
192+
if url.endswith('.txt'):
193+
return b'httpx>=0.27\n'
194+
return b'__plugin_meta__ = {"version": "1.0.0"}\n'
195+
196+
monkeypatch.setattr(install, '_download_file', fake_dl)
197+
result, _ = await install._install_single(
198+
'https://github.com/u/r', '字符字',
199+
path='alone/字符字.py, alone/字符字_requirements.txt', alone=True)
200+
assert result['success'], result
201+
alone = os.path.join(base, 'plugins', 'alone')
202+
assert os.path.isfile(os.path.join(alone, '字符字.py'))
203+
assert os.path.isfile(os.path.join(alone, '字符字_requirements.txt'))
204+
205+
206+
def test_split_paths_forms():
207+
assert install._split_paths(['a.py', 'b.txt']) == ['a.py', 'b.txt']
208+
assert install._split_paths('a.py, b.txt') == ['a.py', 'b.txt']
209+
assert install._split_paths('/alone/a.py/') == ['alone/a.py']
210+
assert install._split_paths('') == []
211+
212+
213+
def test_alone_dep_dest_name():
214+
assert install._alone_dep_dest_name('requirements.txt', '猪猪') == '猪猪_requirements.txt'
215+
assert install._alone_dep_dest_name('字符字_requirements.txt', '猪猪') == '字符字_requirements.txt'
216+
assert install._alone_dep_dest_name('config.json', '猪猪') is None

0 commit comments

Comments
 (0)