pip 套件明明裝好了,卻被自己三月前寫的檔案蓋掉

pip 套件明明裝好了,卻被自己三月前寫的檔案蓋掉

就像信箱外面貼了張「台電公司」的紙條,郵差在門口看到同名就不往裡投了——Python 的 import 機制也是這樣。當前目錄有個跟 pip 套件同名的 .py 檔,直譯器就不去 site-packages 找真貨了。

問題是,這張紙條可能是幾個月前自己貼上去的,早就忘了。

技術環境

Python 3.x,cron 定期任務環境。套件 notion_client 透過 pip install 安裝至系統 site-packages;任務腳本以 python3 script.py 直接呼叫,無前置模組預載流程。問題模式與框架無關——任何當前目錄存在與 pip 套件同名 .py 檔的執行環境,皆會複現相同遮蔽行為,與 Python 版本及作業系統無關。

現象

cron 定期任務在某些環境能跑,換一台就 ModuleNotFoundError。pip list 確認套件在,版本也對,重裝沒用,log 只說「找不到 Client 類別」。

第一時間很容易懷疑是環境沒裝好、pip 指向錯誤直譯器、套件本身有問題。繞了一圈才發現根本不是這些。

錯誤傳染鏈(時序)

cron 觸發           Python 直譯器              import 搜尋路徑
   |                      |                           |
   |── python3 task.py ──>|                           |
   |                      |── 1. 當前目錄掃描 ────────>|
   |                      |<── notion_client.py 命中 ──|  ← 孤兒舊檔,非 pip 套件
   |                      |   搜尋中止,不再走 site-packages
   |                      |                           |
   |                      |── from notion_client import Client
   |                      |<── AttributeError: Client not found  ← 舊版本無此 class
   |<── 任務失敗 ──────────|
環境認知: pip show 顯示套件已安裝 ✓ / 實際載入: 三月前孤兒 .py ✗

直譯器在當前目錄命中同名 .py 即停止搜尋——真正的 pip 套件永遠排在後面,連看都不會被看到。

分界點

能跑的環境,事先透過別的流程把正確模組預載進了記憶體——所以 import 時直接命中已載入的物件,不需要重新走搜尋路徑。裸 python3 直接執行就沒這層緩衝,import 機制從頭走,當前目錄優先,撞名檔案直接上位。

兩個環境表現不同,不是因為套件狀態不同,而是因為 import 有沒有被短路。

容易誤判的原因

錯誤訊息說「找不到 Client」,指向的是類別層級,不是模組層級。自然會去查套件有沒有裝、版本對不對、有沒有 API 改動——這些都沒問題,所以一直繞。

Python 的 import 搜尋順序是:當前目錄 → PYTHONPATH → site-packages。當前目錄裡若有同名 .py 檔,它永遠比 pip 套件先被載入,但錯誤訊息完全不會提示「你載入的是自己的檔案」。被遮蔽的套件不會發出任何聲音。

那個同名檔案是三月留下的 legacy 版本,當時全庫已無任何 import 指向它,只是沒有被清掉。一個安靜躺在目錄裡的孤兒檔,靜靜把整個 cron 流程搞壞了好幾個月。

確認方式

在出問題的環境下執行:

python3 -c "import sys; import notion_client; print(notion_client.__file__)"

如果印出來的路徑指向專案目錄而非 site-packages,就確診了。不需要重裝、不需要比對版本,一行指令直接看 __file__ 屬性,比任何猜測都快。

修法:同名檔案改名加後綴停用(如 .legacy-disabled),同時確認 cron 實際使用的那個 python3 直譯器也裝了正確套件。

Code 對照:修法前後

修法前(問題狀態)

# 專案根目錄
project/
├── notion_client.py     ← 問題在這:與 pip 套件同名的孤兒舊檔
├── cron_task.py
└── main.py

# cron_task.py 執行時 import 的是上面那個孤兒,不是 site-packages 版本
from notion_client import Client  # AttributeError: Client not found

修法後

# 步驟一:確認實際載入的是哪個檔案
python3 -c "import notion_client; print(notion_client.__file__)"
# 若印出 project/notion_client.py → 確診為遮蔽問題

# 步驟二:將孤兒檔移出命名空間
mv notion_client.py notion_client.legacy-disabled

# 步驟三:確認 cron 使用的直譯器也裝了正確套件
which python3 && pip show notion-client

# 修法後
project/
├── notion_client.legacy-disabled   ← 停用,不再遮蔽
├── cron_task.py
└── main.py

from notion_client import Client   # ✓ 正確載入 site-packages 版本

該被隔離的側效應類型

  • cron 定期任務:裸 python3 執行,無模組預載緩衝,import 從頭走,立刻撞名,靜默炸掉。
  • CI/CD pipeline:乾淨環境觸發完整搜尋路徑;本地開發者因 IDE 預載模組看不到問題,CI 先炸。
  • 日誌與告警層:錯誤訊息落在類別層級(「Client not found」)而非模組層級,天然誤導排查方向往版本/API 走。
  • 套件升級流程pip upgrade 沒有效果——升完版本問題依舊,因為孤兒檔一直擋在前面。
  • 多直譯器環境:/usr/bin/python3 vs venv/python3 vs 系統 python,不同直譯器 cwd 行為一致,差異不在 pip,在 cwd 中的孤兒。
  • 測試框架:pytest session-scope fixture 預載了正確模組,單測跑過;切換到 bare python3 跑同一段邏輯就炸,製造「測試有通過但上線就壞」的假象。
  • webhook / callback 處理器:若在獨立 python3 子程序觸發(如 subprocess 呼叫),同樣走完整搜尋路徑,無法靠「主進程跑過」保證正確。

判斷標準:只要執行環境是「裸 python3 + 含孤兒 .py 的當前目錄」組合,任何在該目錄下觸發 import 的流程都屬高風險;應定期跑 __file__ 確認實際載入來源。

留給未來的話

命名是防線。專案內部模組若與常見 pip 套件撞名——requests.pyhttpx.pyutils.py 這類高風險字——遲早會在某個環境製造難以追蹤的靜默錯誤。若必須同名,放進帶前綴的子套件(如 myproject.notion_client

debug import 問題,第一步查 __file__,不是重裝套件。

— 邱柏宇

延伸閱讀


Your Own File Silently Killed the pip Package

There's a file sitting in the project directory, quietly named the same as an installed pip package. No warnings. No import errors about the file itself. Just a Python interpreter doing exactly what it's supposed to do — loading the first match it finds.

The first match happened to be the wrong one.

Technical Environment

Python 3.x, cron job environment. The notion_client package was installed via pip install into system site-packages. Tasks were invoked as bare python3 script.py with no pre-loading of modules before execution. The issue is environment-agnostic — any project directory containing a .py file whose name matches an installed pip package will reproduce the same shadowing behavior, regardless of Python version or OS.

What Happened

A cron job worked in some environments and threw ModuleNotFoundError in others. pip list confirmed the package was installed everywhere. Reinstalling changed nothing. The error message complained about a missing Client class — class level, not module level — which pointed suspicion at the package version, the API surface, everything except the actual cause.

The actual cause: a legacy .py file left over from months prior, no longer imported anywhere in the codebase, just sitting in place. Its name matched the pip package exactly. That was enough.

Error Propagation Sequence

cron trigger        Python interpreter         import search path
   |                      |                           |
   |── python3 task.py ──>|                           |
   |                      |── 1. scan current dir ───>|
   |                      |<── notion_client.py hit ───|  ← orphan legacy file, not pip
   |                      |   search stops; site-packages never reached
   |                      |                           |
   |                      |── from notion_client import Client
   |                      |<── AttributeError: Client not found  ← class absent in old file
   |<── task failure ──────|
Installed: pip shows package present ✓ / Actually loaded: orphan .py from months ago ✗

The interpreter stops at the first match in the current directory — the real pip package never gets a chance to load.

The Dividing Line

Environments that worked had pre-loaded the correct module into memory through a separate process. When the import statement ran, it hit the already-loaded object and skipped the file search entirely. Bare python3 execution had no such shortcut — it walked the import search path from scratch: current directory first, then PYTHONPATH, then site-packages. The shadow file was sitting right at the front of the line.

Two environments, different behavior, same installed packages. The difference was whether import resolution ever reached site-packages at all.

Why It Takes So Long to See

The error message says "Client not found." That reads as a package problem — wrong version, broken install, API change. It doesn't say "you loaded your own file instead of the pip package." The shadowed package makes no noise. The file doing the shadowing makes no noise. The only signal is a class that doesn't exist in a module that loaded successfully.

Python's import precedence — current directory before site-packages — is documented behavior, not a bug. It just rarely surfaces in a way that's immediately legible.

How to Confirm It

In the broken environment, run:

python3 -c "import sys; import notion_client; print(notion_client.__file__)"

If the path points into the project directory instead of site-packages, that's the diagnosis. No reinstalling, no version comparison needed — __file__ shows exactly which file got loaded. One command cuts through everything else.

The fix: rename the legacy file with a suffix like .legacy-disabled to pull it out of the namespace, and make sure the python3 interpreter that cron actually uses has the real package installed.

Code Diff: Before and After

Before (broken state)

# Project root
project/
├── notion_client.py     ← the problem: same name as the pip package
├── cron_task.py
└── main.py

# cron_task.py imports the orphan file, not the pip package
from notion_client import Client  # AttributeError: Client not found

After (fixed)

# Step 1: confirm which file is actually loaded
python3 -c "import notion_client; print(notion_client.__file__)"
# If output points to project/notion_client.py → shadowing confirmed

# Step 2: move the orphan file out of the namespace
mv notion_client.py notion_client.legacy-disabled

# Step 3: verify the cron interpreter has the real package installed
which python3 && pip show notion-client

# Fixed project root
project/
├── notion_client.legacy-disabled   ← disabled, no longer shadowing
├── cron_task.py
└── main.py

from notion_client import Client   # ✓ now loads correctly from site-packages

Side Effects That Should Be Isolated

  • Cron jobs: Bare python3 execution with no module pre-loading. Import walks from scratch every time, hits the orphan immediately.
  • CI/CD pipelines: Clean environments trigger full search-path resolution. Local developers with IDE-preloaded modules never see the failure; CI fails first.
  • Logging and alerting layers: Error messages surface at class level ("Client not found"), not module level — naturally redirecting diagnosis toward version or API issues instead of the shadow file.
  • Package upgrade workflows: pip upgrade has no effect. The orphan file blocks site-packages regardless of which version is installed there.
  • Multi-interpreter environments: /usr/bin/python3 vs venv python3 vs system python behave identically on cwd scanning — the difference isn't in pip, it's in which orphan files sit in cwd.
  • Test frameworks: pytest session-scope fixtures pre-load the correct module; unit tests pass. Switch to bare python3 for the same logic and it breaks — generating false "tests pass but prod fails" confidence.
  • Webhook and callback handlers: Any handler invoked as a subprocess call walks the full import path independently. "Worked in the main process" does not guarantee it works here.

The risk test: if the execution context is "bare python3 + a directory containing orphan .py files," any import triggered in that directory is a shadowing candidate. Run __file__ checks periodically; don't wait for a production incident to find out.

A Note for Later

High-risk names to avoid for internal modules: anything that matches a common pip package — requests, httpx, utils. If the name collision is unavoidable, put the internal module inside a prefixed subpackage (myproject.notion_client) rather than at the top level.

When an import breaks unexpectedly, check __file__ before reaching for pip install. The package is probably fine. The question is which file actually got loaded.

— 邱柏宇

Related Posts