fix(deps): 声明 httpx[http2] / h2,修复干净环境缺包启动失败

utils/api_client.py 用 httpx.AsyncClient(http2=True) 发请求,需要 h2 包。
旧 requirements 只写了 httpx,未声明 http2 extra;开发机碰巧已装 h2 所以
未暴露,干净 release 环境启动即报错:
  Using http2=True, but the 'h2' package is not installed.

- requirements.txt: httpx -> httpx[http2]
- requirements.lock.txt: httpx[http2]==0.28.1,并显式锁 h2==4.1.0
- scripts/gen_requirements_lock.py: 升级为可解析 extras,自动锁定 extra
  引入的传递依赖(如 httpx[http2] -> h2),避免以后重生 lock 丢失 h2;
  通用于任意 extra

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-06-01 17:49:00 +08:00
parent 582f292114
commit 04cdf964af
3 changed files with 69 additions and 16 deletions

View File

@ -13,7 +13,8 @@ flask==2.3.3
flask-socketio==5.3.6 flask-socketio==5.3.6
flask-cors==3.0.10 flask-cors==3.0.10
werkzeug==2.3.7 werkzeug==2.3.7
httpx==0.28.1 httpx[http2]==0.28.1
h2==4.1.0 # 由 httpx[http2] 引入
openai==2.8.1 openai==2.8.1
cryptography==43.0.3 cryptography==43.0.3
pillow==9.3.0 pillow==9.3.0

View File

@ -4,7 +4,7 @@ flask
flask-socketio flask-socketio
flask-cors flask-cors
werkzeug werkzeug
httpx httpx[http2]
openai openai
cryptography cryptography
pillow pillow

View File

@ -35,41 +35,93 @@ _LOCK_HEADER = """\
""" """
def _parse_requirement_names(text: str) -> list[str]: def _parse_requirements(text: str) -> list[tuple[str, list[str]]]:
"""从 requirements.txt 提取包名(忽略注释、空行、已带版本约束的尾巴)。""" """从 requirements.txt 提取 (包名, extras 列表)。
names: list[str] = []
忽略注释/空行去掉版本约束保留 extras
httpx[http2]>=0.28 -> ("httpx", ["http2"]) flask -> ("flask", [])
"""
items: list[tuple[str, list[str]]] = []
for raw in text.splitlines(): for raw in text.splitlines():
line = raw.strip() line = raw.strip()
if not line or line.startswith("#"): if not line or line.startswith("#"):
continue continue
# 去掉版本约束/extras仅留包名 flask[async]>=2 -> flask # 先剥掉版本约束/环境标记: name[extra]>=1 ; marker
name = re.split(r"[<>=!~\[ ;]", line, 1)[0].strip() head = re.split(r"[<>=!~ ;]", line, 1)[0].strip()
if name: m = re.match(r"^([A-Za-z0-9._-]+)(?:\[([^\]]*)\])?$", head)
names.append(name) if not m:
return names 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 == '<name>' 且匹配我们启用的 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: def main() -> int:
if not REQ_PATH.exists(): if not REQ_PATH.exists():
print(f"找不到 {REQ_PATH}", file=sys.stderr) print(f"找不到 {REQ_PATH}", file=sys.stderr)
return 1 return 1
names = _parse_requirement_names(REQ_PATH.read_text(encoding="utf-8")) items = _parse_requirements(REQ_PATH.read_text(encoding="utf-8"))
if not names: if not items:
print("requirements.txt 中没有可锁定的依赖。", file=sys.stderr) print("requirements.txt 中没有可锁定的依赖。", file=sys.stderr)
return 1 return 1
lines: list[str] = [] lines: list[str] = []
missing: 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: try:
version = md.version(name) version = md.version(name)
lines.append(f"{name}=={version}") lines.append(f"{spec}=={version}")
except md.PackageNotFoundError: except md.PackageNotFoundError:
missing.append(name) 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") 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: for line in lines:
print(" " + line) print(" " + line)
if missing: if missing: