Python 排程路徑錯誤常被誤認為腳本消失,實際上可能只是啟動器與任務設定各加了一次目錄。先確認路徑如何組合,再判斷檔案是否存在;腳本沒有執行,就不能回報資料筆數為零。 本文針對本機批次工具與測試排程,建立可重現的路徑契約,不修改任何既有排程設定。

本文採資訊型搜尋意圖,解決「手動能跑、排程找不到檔案」的診斷需求。來源筆記提供重複目錄與未執行狀態的問題脈絡;下方程式則是重新撰寫的合成測試,不引用筆記中的任務識別碼、歷史執行數字或個別環境成果。

相對路徑必須先說明相對於誰

相對路徑只有配合明確的基底目錄才有意義。排程啟動器若已定位到腳本資料夾,任務設定就不應再加同名目錄;先列出基底、設定值與組合結果,才能分辨解析錯誤和檔案真的缺失。

假設測試目錄中只有 scripts/worker.py。若基底已經是 scripts,再加入 scripts/worker.py 就會指向 scripts/scripts/worker.py。這不是 Python 自動複製目錄,也不是作業系統把檔案搬走,而是兩個設定層對「誰負責前綴」沒有共同契約。

工作目錄、腳本所在目錄、排程設定的基底目錄是三個不同概念。 它們可以剛好相同,但不應依賴這個巧合。開發者從專案根目錄啟動時能成功,不能證明另一個啟動器會使用相同位置。排查時應先確定哪一層解析路徑,而不是直接修改業務程式。

建議建立三欄診斷紀錄:設定採用的基底、尚未解析的相對值、解析後是否為檔案。分享紀錄時保留泛用目錄名稱即可,不必輸出完整使用者目錄。若解析結果沒有重複片段但檔案仍不存在,就應查檔名、安裝內容或掛載可用性,而非一律刪除前綴。

pathlib 能正規化,但不會猜測你的意圖

路徑正規化只能依檔案系統語意處理既有片段,不能辨識某個重複目錄是否由設定失誤造成。應用明確基底建立目標,再以嚴格解析和檔案型別檢查拒絕不存在的目標,而不是期待函式自動修補。

Python 的 Path 以目前平台的路徑語意運作。斜線運算子用來組合路徑,但右側若是完整絕對路徑,就可能取代前面的基底。因而「任務只接受相對值」與「任務接受完整位置」應是兩個明確模式,不能讓同一欄位在不同機器上默默改變意思。

resolve(strict=False) 可以形成絕對位置並解析可處理的片段,但不要求整條路徑存在。它不會因為連續出現兩個 scripts 就刪掉其中一個;巢狀同名資料夾原本就是合法結構。本文刻意驗證錯誤路徑經過非嚴格解析後仍然是錯誤路徑,避免將「拿到正規化字串」當成「檔案已通過驗證」。

resolve(strict=True) 對缺失目標會拋出 FileNotFoundError;取得位置後仍應確認 is_file(),因為存在的目錄不是可執行腳本。遇到權限或其他檔案系統錯誤時,應保留失敗分類,不要全部改寫成「找不到」。這些檢查是啟動前診斷,不保證檔案在檢查後永遠不變,也不是完整的檔案存取隔離機制。

分開指定直譯器、腳本位置與工作目錄

可靠的啟動流程會分別確定直譯器、腳本絕對位置和子程序工作目錄。絕對腳本位置能避免腳本查找受工作目錄改變影響,但腳本內部若仍用相對位置讀取資料,仍須另行定義其資料基底。

本機測試使用 sys.executable 啟動目前的 Python,再將已驗證的完整腳本位置作為獨立參數傳入。subprocess.run 使用參數陣列與 shell=False,不把多個引數拼成一條需要額外解讀的命令文字。這能清楚區分程式名稱與路徑,也避免測試依賴終端機的引號習慣。

cwd 決定子程序的工作目錄,不應被誤用成所有平台共通的直譯器查找修復方式。Python 官方文件特別指出可執行檔解析存在平台差異;此處不以未限定的直譯器名稱測試該差異,而是直接沿用目前直譯器。讀者在另一台機器重現時仍須先確認 Python 版本。

若腳本需要旁邊的靜態資源,可考慮以 Path(__file__).resolve().parent 作為資源定位起點;若資源本來屬於工作目錄,就應明確保留這個契約。不要把所有相對資料存取都改成腳本相鄰位置,否則只是從一種隱藏依賴換成另一種。本文 worker 不讀取外部資料,因此驗證範圍僅涵蓋腳本啟動與回傳結果。

驗證與重現:三組本機回歸測試

以下測試在暫存資料夾建立單層腳本目錄,分別驗證重複前綴、工作目錄切換與結果辨識。所有檔案都是測試自行建立的副本,沒有外部服務或既有任務依賴,任一斷言失敗都必須視為驗證未通過。

前置條件:Python 3.11.15、可寫入本機暫存資料夾、可啟動本機子程序。 不需第三方套件,不使用管理者權限,也不需開啟網路。將下方完整程式存成 paths_lab_20260921.py,以一般模式執行,不加會略過斷言的最佳化選項。

  1. 執行 python --version,確認輸出為 Python 3.11.15。將完整程式存於自己的測試資料夾,再執行 python paths_lab_20260921.py;先檢查 check1,應顯示重複位置不存在、錯誤啟動為非零結束碼、未知筆數為 null。
  2. 檢查同一次執行的 check2:嚴格解析應拒絕缺失目標,絕對腳本路徑應能從兩個不同工作目錄啟動,且兩次 JSON 結果完全相同。任一例外或結果差異都是失敗,不應只看是否印出文字。
  3. 檢查 check3:在另一工作目錄使用相對腳本名稱應失敗;成功路徑必須同時具有固定標記、完成狀態與明確的零筆數。最後確認 passed=true 且程序結束碼為零,否則整組驗證未完成。
"""Offline path-resolution regression lab; only disposable temporary files are written."""
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import platform
import subprocess
import sys


def worker():
    print(json.dumps({'marker': 'path-lab-v1', 'state': 'completed', 'count': 0}))


def launch(script, cwd):
    return subprocess.run([sys.executable, str(script), '--worker'], cwd=str(cwd),
                          capture_output=True, text=True, shell=False, timeout=20)


def main():
    if platform.python_version() != '3.11.15':
        raise RuntimeError('This fixture requires Python 3.11.15')
    with TemporaryDirectory(prefix='path-lab-') as folder:
        root = Path(folder).resolve()
        base = root / 'scripts'
        elsewhere = root / 'other'
        base.mkdir(); elsewhere.mkdir()
        target = base / 'worker.py'
        target.write_bytes(Path(__file__).read_bytes())
        wrong = base / 'scripts' / 'worker.py'
        assert target.is_file() and not wrong.exists()
        assert wrong.resolve(strict=False) == wrong
        bad = launch(wrong, base)
        assert bad.returncode != 0 and not bad.stdout.strip()
        check1 = {'duplicateMissing': True, 'badExitNonzero': True,
                  'unresolvedCount': None}
        try:
            wrong.resolve(strict=True)
        except FileNotFoundError:
            strict_rejected = True
        else:
            strict_rejected = False
        absolute = target.resolve(strict=True)
        first = launch(absolute, base)
        second = launch(absolute, elsewhere)
        first.check_returncode(); second.check_returncode()
        a = json.loads(first.stdout); b = json.loads(second.stdout)
        assert strict_rejected and a == b
        check2 = {'strictRejectedMissing': strict_rejected,
                  'cwdIndependent': a == b, 'successfulRuns': 2}
        relative_bad = launch(Path('worker.py'), elsewhere)
        assert relative_bad.returncode != 0
        assert a == {'marker': 'path-lab-v1', 'state': 'completed', 'count': 0}
        try:
            bad.check_returncode()
        except subprocess.CalledProcessError:
            exit_rejected = True
        else:
            exit_rejected = False
        assert exit_rejected
        check3 = {'relativeFailedElsewhere': True, 'nonzeroRejected': exit_rejected,
                  'completedCount': a['count'], 'markerVerified': True}
    print(json.dumps({'python': platform.python_version(), 'check1': check1,
                      'check2': check2, 'check3': check3, 'passed': True}, indent=2))


if __name__ == '__main__':
    if sys.argv[1:] == ['--worker']:
        worker()
    else:
        main()

本次排程環境已實際執行此程式,使用 Python 3.11.15,三組檢查皆通過,程序結束碼為零。check2.successfulRuns=2、check3.completedCount=0,而 check1.unresolvedCount=null。這些是合成測試的實測結果,不代表來源筆記中的歷史任務已經修復,也不代表任何外部資料目前為空。

程式用自己的完整內容建立暫存 worker 副本,並透過 --worker 進入只輸出 JSON 的分支。暫存資料夾結束時會清理本測試建立的檔案;不應把這個目錄替換為既有工作目錄。測試不比較平台特定錯誤訊息或特定失敗碼,只要求非零,避免把作業系統文字差異誤判為功能差異。

把未執行、執行失敗與零結果分開

零結果是腳本成功完成後回傳的資料,不是啟動錯誤的替代值。驗收至少需要完成的程序、可解析的結果與預期任務標記;若其中一項缺失,資料數量應保留未知,不能以預設零值填補。

本例錯誤啟動沒有 worker 輸出,因此只記錄 unresolvedCount=null。相對地,正確啟動具有 state=completed、marker=path-lab-v1 與 count=0。兩者在畫面上都可能沒有資料列,但前者沒有取得觀測,後者則取得了明確的空結果,後續判斷不能混用。

returncode=0 只是一項必要的程序訊號,並不足以驗證回傳內容。若腳本僅印出一般說明文字,JSON 解析應失敗;若回傳其他任務的標記,內容比對應失敗;若程序回傳非零,check_returncode() 應拒絕它。這些判準合起來,才形成此測試的小型輸出契約。

實務上還可加入本次執行識別碼與輸出格式版本,但本文不提供外部寫入或狀態更新範例。若下游需要依結果執行動作,應在它自己的測試環境另建驗收程序。檔案查找通過不等於業務完成,業務完成也不等於所有下游步驟完成,診斷報告應明確指出目前證據停在哪一層。

採用清單、限制與官方依據

導入這套方法的重點是讓每個路徑與結果都有明確責任,而非到處硬編碼完整位置。先用可攜的基底設定產生絕對目標,再在不同工作目錄測試,並保留版本與結果證據,才能讓修正接受獨立檢查。

  • 設定契約: 指定唯一負責加入目錄前綴的層級,說明欄位接受相對值或絕對值。
  • 啟動前檢查: 確認基底可用、目標存在且為檔案,遇到異常立即停止本次啟動。
  • 回歸測試: 保留正確位置、重複前綴、不同工作目錄三種案例,不只測成功路徑。
  • 結果判準: 同時檢查結束碼、可解析格式、任務標記與完成狀態,讓未知與零保持不同。
  • 證據範圍: 區分本機測試通過、特定啟動器已驗證與實際任務完成,不能互相代替。

本文不聲稱路徑改寫會提升效能,也沒有基準測試或成功率承諾。若測試失敗,先依直譯器版本、暫存目錄權限、目標存在性與子程序回傳值排查,不要為了得到綠燈而放寬斷言。另一些環境可能不允許啟動子程序,那是執行前提未滿足,不應被描述為 Python 路徑函式有問題。

官方查核使用 Python 3.11 pathlib 文件,確認路徑組合、嚴格解析與檔案判斷語意;並使用 Python 3.11 subprocess 文件,確認參數陣列、工作目錄、結束碼與目前直譯器的啟動方式。這兩份是會更新的 3.11 分支文件,本文固定的本機實測版本則是 3.11.15,兩者不混為同一份版本快照。

結論:先證明找對腳本,再證明腳本完成。 對本機排程而言,這比看到相同錯誤就反覆重試更有診斷價值,也能避免將尚未取得的資料誤寫成看似正常的零結果。