Skip to main content

建立符合現代標準的專案結構

重要程度:5/5

初學 Python 時大家的專案可能都是直接放在專案根目錄互相 import,不只有 import 關係混亂的問題,也不是 Python 官方建議的專案架構。本文介紹如何建立符合現代標準的 Python 專案,並且說明 packagemodule__init__.py 到底是什麼。

src-layout vs flat-layout

Python 專案佈局分成 src layout 和 flat layout 兩種佈局,多說無益,看的更好理解,結構如下所示

.
├── README.md
├── pyproject.toml
└── awesome_package/
├── __init__.py
└── module.py

這兩種佈局是 Python 負責封裝的管理機構 PyPA 建議的專案佈局方式,也只應該使用這兩種佈局。兩者的差異為

  1. 執行需求:src 佈局強制要求專案必須先安裝才能運行程式碼,而扁平佈局無需安裝即可直接執行。
  2. 匯入安全性:src 佈局在可編輯安裝時,只允許導入 (import) 可導入的檔案,避免意外匯入問題。
  3. 避免意外使用開發中程式碼:同上,最重要的是防止在可編輯模式下可用,正常安裝卻不可用的問題。

src 佈局是新的方式,雖然簡單專案兩者並無差異,但是新專案還是建議使用 src 佈局,大部分沒用 src 佈局的專案都是因為原本就已經使用扁平佈局,修改成本太大。

建立 src 佈局專案

先不學理論,能動再說。建立專案請使用專案管理工具完成,不要再看網路上的害人教學,2025 還在用過時的 venv 跟 conda,請用目前最好的專案管理工具 uv,安裝方式請見 uv 介紹文章。使用 uv 建立 src 佈局專案的方式是

uv init --package --python=3.10 calculation-data-project

這會建立一個 src 佈局、使用 Python 3.10 的專案,並且自動建立好 pyproject.toml 和初始化 Git 版本記錄倉庫。接下來我們建立一個基本專案,建立這些檔案並且貼上程式碼

[project]
name = "calculation-data-project"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "ZhenShuo Leo", email = "your_git_email@gmail.com" }
]
requires-python = ">=3.10"
# 套件依賴(目前無)
dependencies = []

[tool.hatch.build.targets.wheel]
# 指定要打包的 Python 套件路徑,需要建立此段落才可以執行命令行入口,這個段落不會自動建立需要手動設定
packages = ["src/package_A", "src/package_B"]

[project.scripts]
# 專案命令行入口,會在 src/module/cli.py 中執行 main 函式
calculation-cli = "package_A.cli:main"

[build-system]
# 建置系統所需的工具
requires = ["hatchling"]
# 指定建置後端,前端是 uv,呼叫 hatchling 後端進行 build
build-backend = "hatchling.build"

現在我們已經建立好有兩個 package 的專案,並且 package_A 還有 __main__.py 作為 package_A 自己的入口點,接著我們嘗試執行此專案,由於 src 佈局的關係我們一定要安裝專案才能執行,

python3 -m venv .venv
source .venv/bin/activate # Windows: .\.venv\Scripts\activate
pip install -e .

其中 -e 代表可編輯安裝,使用此選項後所有對程式碼的改動都會立刻影響到安裝的套件,如果不使用你就需要每次修改程式碼後手動重新安裝裁可刷新。完成這些步驟後我們終於可以執行腳本

calculation-cli 5 6

這就是傳統運行 Python 專案的流程。但是我們現在有先進的專案管理工具其實不需要這麼麻煩,我們退出虛擬環境並且刪除 .venv 目錄重置環境後,只需要執行此指令 uv 就會自動建立虛擬環境、安裝套件並且執行專案:

uv run calculation-cli 5 6
# 輸出 Calculation Result: 16.5

這裡的邏輯是 uv 會自動尋找 pyproject.toml 並且根據裡面的設定建立環境(如果還沒有環境),再找到 project.scripts 設定的專案的命令行入口點,也就是說會執行 src/package_A/cli.py 裡面的 main 函式。

資訊

uv run 不需進入虛擬環境就可以直接執行虛擬環境中的指令,uv 會自動尋找對應的虛擬環境或是 pyproject.toml。

提示

你可以嘗試在每個檔案前面加上 print 測試他們的載入順序。


什麼是 __init__.py

__init__.py 是一個 package 的必要文件,有這個文件 Python 才會把該目錄作為一個 package 解析,這個文件空白的也可以,每次 import 一個 package 第一步就是讀取這個檔案,需要他的原因很簡單,因為 Python 只知道文件在哪個目錄中,不知道他在哪個 package 中,所以需要 __init__.py 告訴 Python 這是一個 package1

  • 通常我們會在這個檔案把該 package 的函式 import 進來,這樣別人使用時就不用再 from package_A.cli import main,直接使用 from package_A import main 就可以了。
  • 如果加上 __all__ 就代表在其他地方使用 from package_A import * 的時候會包含在 __all__ 裡面定義的函式。
  • 有些命令行工具也會喜歡在 __init__.py 裡面直接設定入口點,例如最知名的影片下載工具 yt-dlp 和圖片下載工具 gallery-dl 都是這樣設定。
  • 我們也可以把元資料放在這裡面,例如 __author__ __license__ 等等,設定範例如下。
# package_A/__init__.py
from package_A.cli import main

__all__ = ["main"]
__author__ = "author name"
__license__ = "MIT License"

什麼是 __main__.py

他不是必要的文件,他是可選的 package 入口點,使用此指令會把 package_A 作為腳本執行:

uv run -m src.package_A 5 6

並且尋找 __main__.py 作為主腳本。-m 代表 module,後面需要跟著一個 module,在此是 src 目錄中的 package_A。

資訊

此指令等效於進入虛擬環境後使用 python3 -m src.package_A,通常沒必要這樣用直接使用 uv run 即可。

提示

在 __init__.py 加上一個 print,試試看 uv run -m src.package_A 5 6uv run calculation-cli 5 6


什麼是 package 和 module

  • module: 任何副檔名是 py 的 Python 檔案
  • package: 一堆 module 的集合,也就是說有一堆 py 檔案的目錄,但是裡面必須包含 __init__.py
  • __init__.py: 告訴 Python 這是一個 package,可以是空白檔案
  • __main__.py: 如果直接執行 package ,會把他作為主函式執行(使用 python -m src.package_A 時會尋找此檔案)

也就是說在剛才的範例中,我們有兩個 package,並且必須使用 __init__.py 才能讓 Python 將他辨識為 package,每個 package 裡面有兩個 module。

在 Python 導入 package 時,該 package 的 __init__.py 也會被執行。


相對導入還是絕對導入

# 相對導入
from . import module1
from .utils import util

# 絕對導入
from package import module1
from package.utils import function1

只建議絕對導入,可避免同名問題並且對 CI 友好,dask 在討論後也改為絕對導入了。

什麼是 __name__ == "__main__"

常常會看到這種語法,例如 cli.py 就有出現這個,究竟是什麼意思呢?

if __name__ == '__main__':
main()

簡短說明:目的是避免特定程式在 import 時被執行。

長一點的說明:目的是區分一個 Python 檔案(模組)是被「直接執行」還是被「作為模組導入」到其他程式中,Python 執行時會為每個文件建立 __name__ 屬性,當文件是頂層腳本,並且被直接執行時會設定為 __main__,否則為模組的名稱,以此區別主程式和被引入的程式。

延伸閱讀

Footnotes

  1. Relative imports for the billionth time, stackoverflow