FR-092 第 6 棒 盤點報告 — pyproject 沒人 import 的依賴

卡號 CM-1709(母卡 CM-1703)。只盤不修:未改任何一行程式碼、未刪任何檔、 未動 pyproject.toml 與 poetry.lock(連下面 C 類「套件該補宣告」也只寫在這裡), 未連任何資料庫。

§1

範圍與方法

盤點對象是主專案 pyproject.toml 的 [project].dependencies——取 HEAD 版本 (git show HEAD:pyproject.toml),共 62 支未註解的宣告(39 支第三方 + 18 支 jedi + gunicorn 等承載)。

⚠️ 為什麼不用工作目錄那份:開工時(2026-09-12)工作目錄的 pyproject.toml 有 FR-080 未 commit 的 path override,把 11 支 jedi 套件的 pin 改成註解、另加 dev group 的 worktree 指向,檔頭自己註明「不 commit」。那是開發期暫態,不是宣告事實, 拿它當基準會把 11 支 jedi 套件全誤判成「沒宣告」。

判斷「有沒有人 import」不用 grep 逐支掃,改成一次性建 AST import 索引:

  1. 用 ast 解析主專案與 jedi monorepo 全部 .py,抓 Import/ImportFrom 的頂層模組名,外加 importlib.import_module("x") 與 __import__("x") 的字串引數。
  2. AST 會抓到縮排內的 lazy import(函式內 import),grep '^import' 這種行首 錨定的寫法抓不到——而本 codebase 的 AI client、PDF 後端都是函式內 lazy import, 純靠行首 grep 會直接誤判成零使用。
  3. 依路徑切 scope:主專案執行期(api app common config core di_containers domain infra main.py)/ scripts//test//其他(bin content docs examples)/各 jedi 套件 src/各 jedi 套件自己的 tests。
  4. 反查「誰把某套件當相依帶進來」用 importlib.metadata,區分硬相依與 extra == 選用相依。

索引共 17,777 筆(去掉 vendored 目錄前是 30,803 筆)。

工具:deptry 0.25.1(裝在 scratchpad,未進 pyproject.toml)、Python 3.11.9、 手寫 AST 腳本。deptry 報 1,120 條,其中 DEP002(宣告未用)11 條、DEP001(未宣告)89 條、 DEP003 102 條、DEP004 918 條——DEP004 幾乎全是雜訊(它把 dev group 的 path override 當「宣告成 dev 依賴」,於是 test/ 裡每一支 import pytest 都報一條)。結論不採 deptry 原始輸出,每條都另外開檔核對過。

一個一定要避開的陷阱:vendored 目錄

第一輪 C 類(套件用了但沒宣告)判了 7 支套件共 20 條,逐一開檔後發現多數命中落在 jedi-compliance-audit/.venv-standalone/lib/python3.11/site-packages/ 底下——那是套件 自己的 standalone 測試環境,裡面的 flask/app.py、jwt/algorithms.py 當然 import werkzeug 與 cryptography,但那是第三方套件自己的原始碼,不是我方寫的。 沒濾掉會得出「jedi-compliance-audit 用了 PIL/numpy/redis 卻沒宣告」這種完全錯誤的結論 (實際上那三條分別來自 pygments、psycopg、pip 的 vendored 副本)。

濾掉 .venv*/site-packages/build/dist/.tox 後,C 類從 20 條收斂到 11 條。 教訓與第 7 棒的「靜態抓 __tablename__ 會漏」同型:機械命中必須開檔看過路徑, 才知道那一行是誰寫的。

統計

項目 數量
HEAD 宣告的依賴 62(第三方 39/jedi 18/其他 5)
主專案執行期 import 數為 0 的候選 14
└ 逐一核對後確定可拔(A 類) 3
└ 主專案可拔、但屬套件領域(B 類) 6
└ 傳遞相依/動態載入,不可拔(D 類) 5
反向:主專案執行期有 import 但 HEAD 沒宣告 7 支(其中 openpyxl 11 檔為最嚴重)
C 類:jedi 套件 src 用了但自己沒宣告 11 條(跨 7 支套件)
條件性發現:第 2 棒刪檔後才歸零的 2 支(pytesseract/Pillow,結論見下)

§2

一、宣告了但沒人用(A-D 分類)

分類定義照卡片:A 主專案與套件都零用、build 也沒列 → 可拔;B 主專案零用但某 jedi 套件在用且已自行宣告 → 主專案可拔;C 套件在用但沒宣告 → 主專案暫不可拔; D build 腳本明列或動態載入 → 不可拔。

套件 版本約束 主專案 import jedi 套件 import 套件端有宣告 build 清單 分類 建議
gevent ==24.11.1 0 0(全 monorepo 零) — 未列 A 可拔,連 zope.event/zope.interface 共約 9.7 MB
flask-marshmallow ==1.2.1 0 0 — 未列 A 可拔(0.03 MB,但屬純噪音宣告)
google-auth-oauthlib >=1.2.0,<2.0.0 0 0 — 未列 A 可拔,連 requests-oauthlib/oauthlib 一起走
ldap3 >=2.9.1,<3.0.0 0 jedi-iam ✓ pyproject.toml:41 未列 B 主專案可拔
pyotp >=2.9.0,<3.0.0 0 jedi-iam ✓ :45 未列 B 主專案可拔
qrcode[pil] >=8.2,<9.0 0 jedi-iam ✓ :46 未列 B 主專案可拔
pydantic >=2.12.4,<3.0.0 0 jedi-notification ✓ :19 未列 B 主專案可拔
defusedxml >=0.7.1,<0.8.0 0 jedi-flow-engine ✓ :30 未列 B 主專案可拔
babel >=2.17.0,<3.0.0 0 — flask-babel 硬相依 Babel>=2.12 未列 B' 可拔,但它是 flask-babel 的硬相依,拔了照樣會裝
psycopg ==3.2.4 0 7 支套件 ✓ 未列 D 不可拔(理由見下)
psycopg-binary ==3.2.4 0(無 import 名) — jedi-common 宣告 未列 D 不可拔(同上)
anthropic >=0.77.0,<0.78.0 0 jedi-ai-bot/jedi-ai-dashboard ai-bot ✓/ai-dashboard 刻意只放 extras grpc 相依鏈 D 不可拔(理由見下)
openai >=2.8.1,<3.0.0 1(api/translate/routes/translate_route.py) jedi-ai-dashboard 刻意只放 extras 未列 D 不可拔
google-generativeai >=0.8.6,<0.9.0 0 jedi-ai-dashboard 刻意只放 extras grpc 在 EXCLUDE_COMPILE_PKGS D 不可拔(理由見下)
google-auth-httplib2 >=0.2.0,<0.4.0 0 — google-api-python-client 硬相依 未列 D 可拔但無意義,是 API client 的硬相依

D 類的四個「看起來沒人用、其實動不得」

這四條是本棒最值得記下來的——零 import 不等於零使用:

  1. psycopg / psycopg-binary——主專案執行期一次 import 都沒有,但 core/app_factory.py:130 組的 DB URL 是 postgresql+psycopg://,SQLAlchemy 靠這個字串在執行期動態載入 DBAPI driver。拔掉會在連 DB 的第一刻炸 ModuleNotFoundError: No module named 'psycopg',而且靜態掃描永遠看不到這條線。 (psycopg-binary 是 psycopg 的 C 加速輪,本身沒有 import 名。)

  2. 三家 LLM SDK(anthropic/openai/google-generativeai)——jedi-ai-dashboard 刻意不把它們放進 runtime dependencies,只放 [project.optional-dependencies] 的 claude/openai/google 三個 extras,檔頭寫明「consumer 依實際要用的供應商自行安裝」。 而主專案 di_containers/ai_dashboard/ai_dashboard_containers.py:55-59 把 三家的金鑰全部接上(claude/openai/google), AIClientFactory.get_client(provider) 用字串分派、三支 client 都是函式內 lazy import。 也就是說:三家都是產品實際供應的功能,主專案就是那個「該裝的 consumer」。 這三支宣告不是殘留,是套件契約要求宿主提供的東西,一支都不能拔。

  3. google-auth-httplib2——google-api-python-client 的硬相依,拔掉宣告它照樣會被裝。 列成 D 只是說「拔它不會有效果」,不是「拔它會壞」。

A 類三支的來歷(查過 git log)

  • gevent(be36928b init)與 flask-marshmallow(同一個 init commit)—— 從專案第一個 commit 就在,從來沒有任何一行程式碼 import 過它們。 gevent 唯一的提及是 core/scheduler.py:9 的一句 docstring 「更嚴謹的做法是改用 apscheduler.schedulers.gevent」——那是假設語氣的建議,不是使用。 另已確認 gunicorn 沒有設 worker_class(main.py:236-245 只設 bind/workers/timeout), 跑的是預設 sync worker;SocketIO 模式用的是 eventlet(core/app_factory.py:172 async_mode="eventlet")。兩個承載模式都不碰 gevent。
  • google-auth-oauthlib(90b1039e feat: 新增 Google Drive OAuth 整合模組)—— 當初為 Drive OAuth 加的,但實作最後手刻 requests 直打 Google HTTP 端點 (infra/cloud_integration/google_drive/google_oauth_client.py,四個 URL 常數寫死), 沒走 oauthlib 那套 Flow。宣告從加進來那天就沒被用過。

一支已經因為套件搬遷而失效的宣告

defusedxml 的來歷值得單獨記:它是 92145244 fix(security) 為了防 XXE 加的, 當時改的是主專案 app/flow_engine/util/bpmn_generator.py。FR-069 把 BPMN 產生器 搬進 jedi-flow-engine 之後,主專案那支檔案已不存在(find 零命中), defusedxml 的使用點跟著走了,套件那邊也正確宣告了(:30),只有主專案的宣告留在原地。 這是「套件搬走、宿主宣告沒收」的標準形狀,B 類其餘幾支(ldap3/pyotp/qrcode/pydantic) 同型——MFA、LDAP 登入都還是活功能,只是實作全在 jedi-iam 裡。


§3

二、反向:主專案有 import 但 HEAD 沒宣告

這一段是卡片指定要附的。這類比「宣告了沒用」危險得多:它們現在能跑,純粹因為某個 jedi 套件或第三方套件把它們當相依帶進來了——那支套件的 pin 一換、或它哪天把該相依 拔掉,主專案就會在執行期炸 ImportError,而 pyproject.toml 上完全看不出來。

套件 主專案執行期 import 靠誰帶進來(硬相依) 風險
openpyxl 11 檔(存活)+2 檔在第 2 棒死檔清單 jedi-compliance-audit >=3.1.5,<4、jedi-survey >=3.1,<4、jedi-oscal-v2 >=3.1.0、jedi-log >=3.1.4 🔴 最高——xlsx 匯入匯出是產品核心功能,卻整支靠套件借來
werkzeug 8 檔(存活)+1 死檔 flask >=3.1.0(以及 flask-cors/flask-jwt-extended/jedi-file-upload/jedi-log) 中(Flask 在,werkzeug 就在,但仍該顯式宣告)
cryptography 1 檔 infra/cloud_integration/crypto/fernet_crypto.py jedi-license-runtime/jedi-remote-agent >=42.0,<47、google-auth、pdfminer.six 中高——加密金鑰處理卻沒自己宣告版本下限
requests 1 檔 infra/cloud_integration/google_drive/google_oauth_client.py jedi-iam/jedi-notification >=2.31,<3、google-api-core、pygithub 中
httpx 1 檔 infra/upload_file/remote_agent_adapter.py jedi-detection/jedi-license-runtime/jedi-remote-agent >=0.28,<0.29、anthropic、openai 中
PyJWT(import jwt) 1 檔(在第 2 棒死檔清單內) flask-jwt-extended <3.0,>=2.0 低(用它的檔要被刪)
tqdm 1 檔(在第 2 棒死檔清單內) jedi-oscal-v2 >=4.66.0、openai、google-generativeai 低(同上)

openpyxl 值得單獨開一張修正卡

11 支存活的執行期檔案 import 它,全是模組層 from openpyxl import ...(已開檔核對, 不是註解也不是字串):

app/auth/service/user_import_template_app_service.py
app/feedback/service/feedback_service.py
app/flow_control/service/job_import_service.py
app/module_frame/excel_template/data_validation_builder.py
app/module_frame/excel_template/generator.py
app/module_frame/excel_template/lookup_builder.py
app/module_frame/excel_template/styles.py
app/module_frame/service/module_frame_template_import_service.py
app/oscal/service/excel_parser/parser.py
app/oscal/service/excel_parser/sheet_handlers.py
app/oscal/service/ssp_control_impl_import_service.py

其中 excel_template/generator.py 經 app/module_frame/service/ssp_import_template_app_service.py:77 被 DI 注入使用, 是活端點打得到的路徑。scripts/build/ 對 openpyxl 零提及,所以它不在 EXCLUDE_COMPILE_PKGS 也沒被 --include-package,是靠 Nuitka 追 import 圖收進去的—— 只要那四支套件哪天都不再需要 openpyxl,出貨產物會少掉這個套件,而 build 不會報錯。


§4

三、C 類:jedi 套件 src 用了但自己沒宣告(供首腦轉給套件端)

本棒不動套件,只列出來。 已濾掉 vendored 目錄,每條都開檔確認是套件自己寫的 import。

套件 用了什麼 檔數 代表檔案 是否被傳遞覆蓋
jedi-integrity sqlalchemy 1 infra/tamper_event_repo.py:25 from sqlalchemy import text ⚠️ 只靠 jedi-common>=0.0.30 間接帶(該套件 dependencies 只有 jedi-common 一行)
jedi-detection dependency-injector 2 api/routes/detection_profile_route.py:17 ❌ 自己沒宣告,主專案有宣告 → 現在能跑純屬巧合
jedi-detection flask-babel 2 app/service/detection_orchestration_service.py ❌ 自己沒宣告
jedi-detection python-docx 1 profiles/tools/twgcb2inspec.py(工具腳本,非執行期) ❌
jedi-detection werkzeug 1 app/service/detection_result_handler.py ✓ Flask 帶
jedi-compliance-audit rapidfuzz 1 app/service/ar_import/evidence_matcher.py ❌ 自己沒宣告,主專案有
jedi-compliance-audit werkzeug 2 app/service/ap_docx_import_app_service.py ✓ Flask 帶
jedi-common werkzeug 1 handler/handler.py ✓ Flask 帶
jedi-iam werkzeug 1 app/service/import_user_service.py ✓ Flask 帶
jedi-issue werkzeug 9 app/issue/service/issue_service.py 等 ✓ Flask 帶
jedi-survey werkzeug 1 api/routes/task_survey_route.py ✓ Flask 帶

優先序:jedi-integrity → sqlalchemy、jedi-detection → dependency-injector、 jedi-compliance-audit → rapidfuzz 三條最該補——它們不是靠 Flask 那種必然存在的相依 帶進來的,而是靠「主專案剛好也宣告了同一支」才活著。werkzeug 那六條屬於 「Flask 在它就在」,優先序低但仍建議顯式化。

FR-069 D9 曾抓到套件端 4 個虛掛依賴,這 11 條是同一類問題的另一面(漏宣告,而非虛掛)。


§5

四、條件性發現:第 2 棒(CM-1705)刪檔後才成立的

第 2 棒要刪 common/util/pdf_util.py、pdf_converter_v2.py、nist171_converter_v1.py、 captcha_image_util.py、excel_util.py、file_util.py 等。把死檔清單與 import 索引 交叉後,有幾條要提醒首腦:

套件 現在 第 2 棒之後 建議
pytesseract 1 檔(pdf_util.py,死檔) 0 可與第 2 棒同批拔;它是 OCR,拔掉連帶 Pillow 需求也少一個
Pillow 2 檔(captcha_image_util.py+pdf_util.py,兩支都是死檔) 0 ⚠️ 宣告可拔,但套件照樣會裝——它是 pdfplumber >=12.2.0、weasyprint >=9.1.0、qrcode[pil] 的硬相依。拔宣告只是讓依賴關係誠實,省不到體積
pdfplumber 2 檔 1(pdf_backend.py) 不可拔
pandas 3 檔 2 不可拔(且在 EXCLUDE_COMPILE_PKGS,是編譯時長大戶)

🔴 一個第 2 棒清單的缺口:common/util/pdf_backend.py

pdf_backend.py 不在 dead-files.txt 上,但它的全部 consumer 只有三支—— pdf_util.py、pdf_converter_v2.py、nist171_converter_v1.py——三支都在第 2 棒的刪除清單裡。 第 2 棒做完之後,pdf_backend.py 會變成零 consumer 的孤兒(已 grep 全 repo 含 scripts//test//content//bin/ 確認)。

它是 CM-1235 拔 PyMuPDF(AGPL) 時寫的 fitz 相容層(336 行),也是 pdfplumber 與 pypdfium2 在主專案的唯一使用點。要嘛第 2 棒把它一起刪(那 pdfplumber/pypdfium2 也跟著 變成零使用,可一併拔宣告,省下 pypdfium2 綁的 PDFium 原生庫與它在 EXCLUDE_COMPILE_PKGS 的一席),要嘛留著當未來 PDF 匯入的後端——但留著就是留一支沒人呼叫的 336 行程式碼。 這個取捨是首腦的決定,本棒只把事實列出來。

另一個發現:captcha_image_util.py 是「從來就跑不起來」的死碼

它 from captcha.image import ImageCaptcha,而 captcha 這個套件: 主專案沒宣告、任何 jedi 套件都沒宣告、venv 裡也沒裝 (poetry run python -c "import captcha" → ModuleNotFoundError)。 也就是說這支檔案在目前環境下一 import 就會炸,不是「沒人用的活碼」而是 「壞掉的死碼」。它已在第 2 棒清單(dead-files.txt:64),刪掉就對了; 記在這裡是因為它同時解釋了 deptry 的 DEP001 'captcha' imported but missing 那一條不是誤報。


§6

五、建議的修正卡切法

若要開修正卡,建議切成三張(都很小,可併成一張中卡):

  1. 拔 A 類三支宣告(gevent/flask-marshmallow/google-auth-oauthlib)—— 純刪三行 + poetry update。驗證:python main.py 起得來、RUN_MODE=socketio 起得來 (驗 eventlet 路徑沒被影響)。省約 9.8 MB 與三支套件的授權/CVE 面。
  2. 補反向 7 支宣告(openpyxl 為首,+werkzeug/cryptography/requests/httpx)—— 加宣告不改行為,但把「靠套件借來」變成「自己宣告」。openpyxl 最該先做。 PyJWT/tqdm 等第 2 棒刪完再看還需不需要。
  3. B 類六支(ldap3/pyotp/qrcode/pydantic/defusedxml/babel)—— 技術上可拔,但它們全都是「套件在用、套件也宣告了」,拔掉屬於清理宣告噪音, 風險低但收益也低(都會被傳遞裝回來)。建議與 2 併做或暫緩,不必單獨開卡。

C 類 11 條屬套件異動,依 CLAUDE.md 外部套件規範要先提醒、由決策者點頭,本棒不動。

§7

附錄:命令重現

S=<scratchpad>
poetry run python -m pip install -q --target "$S/tools" deptry
PYTHONPATH="$S/tools" poetry run python -m deptry . --ignore-notebooks --json-output "$S/deptry.json"
git show HEAD:pyproject.toml > "$S/pyproject.HEAD.toml"   # 基準取 HEAD,不取工作目錄
# AST 索引腳本見報告「範圍與方法」,核心是 ast.walk 抓 Import/ImportFrom + import_module 字串引數,
# 並濾掉 .venv*/site-packages/build/dist/.tox