diff --git a/requirements.lock.txt b/requirements.lock.txt index 13e2dce..6680f3b 100644 --- a/requirements.lock.txt +++ b/requirements.lock.txt @@ -13,7 +13,8 @@ flask==2.3.3 flask-socketio==5.3.6 flask-cors==3.0.10 werkzeug==2.3.7 -httpx==0.28.1 +httpx[http2]==0.28.1 +h2==4.1.0 # 由 httpx[http2] 引入 openai==2.8.1 cryptography==43.0.3 pillow==9.3.0 diff --git a/requirements.txt b/requirements.txt index fef2642..7a4667b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ flask flask-socketio flask-cors werkzeug -httpx +httpx[http2] openai cryptography pillow diff --git a/scripts/gen_requirements_lock.py b/scripts/gen_requirements_lock.py index ba0bad7..8df0dde 100644 --- a/scripts/gen_requirements_lock.py +++ b/scripts/gen_requirements_lock.py @@ -35,41 +35,93 @@ _LOCK_HEADER = """\ """ -def _parse_requirement_names(text: str) -> list[str]: - """从 requirements.txt 提取包名(忽略注释、空行、已带版本约束的尾巴)。""" - names: list[str] = [] +def _parse_requirements(text: str) -> list[tuple[str, list[str]]]: + """从 requirements.txt 提取 (包名, extras 列表)。 + + 忽略注释/空行;去掉版本约束;保留 extras。 + 例: httpx[http2]>=0.28 -> ("httpx", ["http2"]) ; flask -> ("flask", []) + """ + items: list[tuple[str, list[str]]] = [] for raw in text.splitlines(): line = raw.strip() if not line or line.startswith("#"): continue - # 去掉版本约束/extras,仅留包名: flask[async]>=2 -> flask - name = re.split(r"[<>=!~\[ ;]", line, 1)[0].strip() - if name: - names.append(name) - return names + # 先剥掉版本约束/环境标记: name[extra]>=1 ; marker + head = re.split(r"[<>=!~ ;]", line, 1)[0].strip() + m = re.match(r"^([A-Za-z0-9._-]+)(?:\[([^\]]*)\])?$", head) + if not m: + continue + name = m.group(1) + extras = [e.strip() for e in (m.group(2) or "").split(",") if e.strip()] + items.append((name, extras)) + return items + + +def _extra_dependency_names(pkg: str, extras: list[str]) -> list[str]: + """解析某包在指定 extras 下引入的依赖包名(用于一并锁定其传递依赖)。 + + 例: httpx + ["http2"] -> ["h2"]。仅返回包名,版本由调用方查实际安装值。 + 无法解析时返回空列表(静默降级)。 + """ + if not extras: + return [] + try: + requires = md.requires(pkg) or [] + except md.PackageNotFoundError: + return [] + wanted = {e.lower() for e in extras} + found: list[str] = [] + for req in requires: + # 仅取声明了 extra == '' 且匹配我们启用的 extra 的依赖 + if "extra ==" not in req: + continue + if not any(f"extra == '{e}'" in req or f'extra == "{e}"' in req for e in wanted): + continue + dep = re.split(r"[<>=!~ ;]", req.strip(), 1)[0].strip() + dep = re.sub(r"\[[^\]]*\]", "", dep) # 去掉 extra 自身可能带的 [] + if dep and dep not in found: + found.append(dep) + return found def main() -> int: if not REQ_PATH.exists(): print(f"找不到 {REQ_PATH}", file=sys.stderr) return 1 - names = _parse_requirement_names(REQ_PATH.read_text(encoding="utf-8")) - if not names: + items = _parse_requirements(REQ_PATH.read_text(encoding="utf-8")) + if not items: print("requirements.txt 中没有可锁定的依赖。", file=sys.stderr) return 1 lines: list[str] = [] missing: list[str] = [] - for name in names: + seen: set[str] = set() # 去重(extra 依赖可能与直接依赖重复) + + for name, extras in items: + spec = f"{name}[{','.join(extras)}]" if extras else name try: version = md.version(name) - lines.append(f"{name}=={version}") + lines.append(f"{spec}=={version}") except md.PackageNotFoundError: missing.append(name) - lines.append(f"# {name} (未安装,无法锁定版本)") + lines.append(f"# {spec} (未安装,无法锁定版本)") + seen.add(name.lower()) + + # 一并锁定该包在启用 extras 下引入的依赖(如 httpx[http2] -> h2), + # 否则干净环境装 lock 时不会拉到这些可选依赖。 + for dep in _extra_dependency_names(name, extras): + if dep.lower() in seen: + continue + seen.add(dep.lower()) + try: + dep_ver = md.version(dep) + lines.append(f"{dep}=={dep_ver} # 由 {name}[{','.join(extras)}] 引入") + except md.PackageNotFoundError: + missing.append(dep) + lines.append(f"# {dep} (由 {name} extra 引入,但未安装)") LOCK_PATH.write_text(_LOCK_HEADER + "\n" + "\n".join(lines) + "\n", encoding="utf-8") - print(f"已写出 {LOCK_PATH}({len(names)} 项依赖)") + print(f"已写出 {LOCK_PATH}({len(items)} 项直接依赖 + extra 传递依赖)") for line in lines: print(" " + line) if missing: