99"""
1010
1111import asyncio
12+ import contextlib
1213import importlib
1314import importlib .metadata as _metadata
1415import os
1516import re
1617import subprocess
1718import sys
19+ import tempfile
1820import threading
1921
2022from 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+
94155def _mirror_attempts ():
95156 """返回按优先级排列的镜像参数列表; '' 表示官方源 (不带 -i)。
96157
@@ -128,27 +189,25 @@ def _build_cmd(req_path, mirror, no_cache, target=None):
128189
129190
130191async 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 )
0 commit comments