Packaging & pyproject.toml
Python package คือไดเรกทอรีของโมดูลที่สามารถติดตั้ง กำหนดเวอร์ชัน และแชร์ได้ การทำ Packaging จะเปลี่ยนโค้ดในเครื่องของคุณให้เป็นสิ่งที่ทุกคนสามารถติดตั้งได้ด้วย pip install mypackage หรือ uv add mypackage การเข้าใจ Packaging เป็นสิ่งจำเป็นทันทีที่โปรเจกต์ของคุณเติบโตเกินกว่าสคริปต์ไฟล์เดียว หรือเมื่อคุณต้องการแชร์งานกับเพื่อนร่วมทีมหรือสาธารณะ
บทนี้ไม่มีปุ่ม Run — packaging, build และ publish ต้องรันในเครื่อง:
uv init --lib mypackage && cd mypackage && uv build
โครงสร้าง Package
หัวข้อที่มีชื่อว่า “โครงสร้าง Package”การใช้ src-layout แบบเรียบร้อยจะแยกซอร์สโค้ดออกจากการทดสอบและ tooling ป้องกันการ import โค้ดที่ยังไม่ได้ติดตั้งโดยบังเอิญ uv init --lib mypackage สร้างโครงสร้างแบบนี้ให้เลย:
$ uv init --lib mypackage && find mypackage -not -path '*/.git*' | sortmypackagemypackage/.python-versionmypackage/README.mdmypackage/pyproject.tomlmypackage/srcmypackage/src/mypackagemypackage/src/mypackage/__init__.pymypackage/src/mypackage/py.typedflowchart TD root["mypackage/"] --> src["src/"] src --> pkg["mypackage/"] pkg --> init["__init__.py"] pkg --> core["core.py"] pkg --> cli["cli.py"] pkg --> typed["py.typed (empty marker file)"] root --> tests["tests/"] tests --> testcore["test_core.py"] root --> pyproj["pyproject.toml"] root --> readme["README.md"]
py.typed เป็นไฟล์ว่างจริง ๆ (PEP 561) — หน้าที่เดียวของมันคือการมีอยู่ เพื่อบอก type checker ว่าคนที่ใช้ package นี้สามารถเชื่อ type hint ของมันได้ แทนที่จะมองว่าเป็น package ที่ไม่มี type
pyproject.toml
หัวข้อที่มีชื่อว่า “pyproject.toml”pyproject.toml คือแหล่งข้อมูลหลักสำหรับ package ของคุณ ตาราง [build-system] ระบุ build backend และ [project] ประกาศ metadata ทั้งหมดที่ pip และ PyPI ต้องการ
[build-system]requires = ["hatchling"]build-backend = "hatchling.build"
[project]name = "mypackage"version = "0.1.0"description = "A short description of mypackage"readme = "README.md"requires-python = ">=3.14"license = "MIT"authors = []dependencies = [ "httpx>=0.27",]
[project.scripts]mypackage-greet = "mypackage.cli:main"
[project.urls]Homepage = "https://github.com/yourname/mypackage"license = "MIT" คือ SPDX license expression string (PEP 639) — รูปแบบสมัยใหม่ รูปแบบเก่า license = { text = "MIT" } แบบ table ถูก deprecate ไปแล้ว; ให้ใช้ string เปล่า ๆ และเพิ่ม license-files = ["LICENSE*"] ถ้าคุณแนบไฟล์ license ไปด้วย requires-python คือเวอร์ชันต่ำสุดที่คุณรับประกัน — pip และ uv จะไม่ยอมติดตั้งบน interpreter ที่เก่ากว่านั้น ให้ตั้งเป็นเวอร์ชันเก่าสุดที่คุณทดสอบจริง distutils ถูกถอดออกใน Python 3.12 แล้ว: ต้องมี build backend อย่าง hatchling (ตามตัวอย่างข้างบน), setuptools หรือ uv_build (backend ของ uv เอง — uv init --lib ใช้ตัวนี้เป็นดีฟอลต์) และ setup.py ไม่ใช่จุดเริ่มต้นอีกต่อไป
[dependency-groups] (PEP 735) เทียบกับ [project.optional-dependencies]
หัวข้อที่มีชื่อว่า “[dependency-groups] (PEP 735) เทียบกับ [project.optional-dependencies]”ทั้งสองอย่างจัดกลุ่ม dependency เพิ่มเติม แต่ตอบคำถามคนละอย่าง [project.optional-dependencies] ประกาศ extra ที่ติดตั้งได้ของ package ที่เผยแพร่ — ผู้ใช้สามารถ pip install mypackage[dev] เพื่อเลือกใช้ได้ [dependency-groups] (PEP 735 ตอนนี้เป็นมาตรฐานของตัวมันเองแล้ว ไม่ใช่แนวคิดเฉพาะของ Poetry/PDM อีกต่อไป) ประกาศ กลุ่มสำหรับพัฒนาเท่านั้นที่ไม่เคยถูกเผยแพร่ — มันมีไว้เพื่อ uv sync/uv run ใน repo นี้เท่านั้น
[dependency-groups]dev = [ "pytest>=8", "mypy>=1.10",]lint = [ "ruff>=0.16",]uv sync --group dev --group lint # installs main deps + both groups, into .venvuv run pytest -q # uses that environmentรันจริง — uv sync resolve ทุกอย่าง และ script entry point ของโปรเจกต์เอง (ประกาศไว้ใน [project.scripts] ข้างบน) ใช้งานได้ทันทีผ่าน uv run ไม่ต้องมีขั้นตอนติดตั้งแยก:
$ uv sync --group dev --group lintResolved 20 packages in 336ms Building mypackage @ file:///.../mypackage Built mypackage @ file:///.../mypackagePrepared 1 package in 1.03sInstalled 19 packages in 53ms + anyio==4.15.1 + httpx==0.28.1 + mypackage==0.1.0 (from file:///.../mypackage) + mypy==2.3.1 + pytest==9.1.1 + ruff==0.16.8 ...$ uv run mypackage-greet AdaHello, Ada!$ uv run pytest -q. [100%]1 passed in 0.00sใช้ [project.optional-dependencies] สำหรับสิ่งที่ คนใช้ package ที่เผยแพร่ของคุณอาจต้องการ (mypackage[cli], mypackage[postgres]); ใช้ [dependency-groups] สำหรับสิ่งที่มีแค่ คนที่ contribute ให้ repo นี้ ต้องการ (dev, lint, docs) — พวกมันไม่เคยถูกส่งไปกับ wheel เลย
Tool configuration ก็อยู่ใน pyproject.toml เหมือนกัน
หัวข้อที่มีชื่อว่า “Tool configuration ก็อยู่ใน pyproject.toml เหมือนกัน”[tool.ruff]line-length = 100target-version = "py314"
[tool.ruff.lint]select = ["E", "F", "B", "UP"]
[tool.mypy]strict = true
[tool.pytest.ini_options]testpaths = ["tests"]นี่ไม่ใช่ตัวอย่างสมมติ — มันคือ pyproject.toml ของ scratch project ของบทนี้เอง และ checker ทุกตัวข้างล่างรันกับ config นี้เป๊ะ ๆ:
$ uvx ruff check src/All checks passed!
$ uv run mypy src/mypackageSuccess: no issues found in 3 source files[tool.ruff]/[tool.mypy]/[tool.pytest.ini_options] หมายความว่าทุก contributor และทุก CI job รัน configuration เดียวกัน โดยอัตโนมัติ — ไม่ต้องมีไฟล์ .flake8, mypy.ini, pytest.ini แยกให้ sync กัน และไม่มีความเสี่ยงที่ checker จะใช้ค่าดีฟอลต์แบบเงียบ ๆ เพราะหาไฟล์ config ไม่เจอ
ไฟล์ซอร์สโค้ด
หัวข้อที่มีชื่อว่า “ไฟล์ซอร์สโค้ด”Public API จะถูก re-export จาก __init__.py เพื่อให้ import surface สะอาดเรียบร้อย
from mypackage.core import greet
__all__ = ["greet"]Business logic อยู่ในโมดูลเฉพาะ เช่น core.py
def greet(name: str) -> str: """Return a greeting string.""" return f"Hello, {name}!"[project.scripts] — entry point CLI จริง
หัวข้อที่มีชื่อว่า “[project.scripts] — entry point CLI จริง”[project.scripts] map ชื่อคำสั่งไปยัง module:function — uv/pip จะ generate executable wrapper เล็ก ๆ ตอนติดตั้ง ที่ import โมดูลแล้วเรียก function นั้น ไม่ต้อง hack sys.path เอง ไม่ต้องมี if __name__ == "__main__" shim เพื่อให้รันเป็นคำสั่งได้
# src/mypackage/cli.py -- exercised as `uv run mypackage-greet` aboveimport sys
from mypackage.core import greet
def main() -> None: name = sys.argv[1] if len(sys.argv) > 1 else "world" print(greet(name))Editable install, การ Build และการเผยแพร่
หัวข้อที่มีชื่อว่า “Editable install, การ Build และการเผยแพร่”ในระหว่างการพัฒนา คุณต้องการให้การเปลี่ยนแปลงโค้ดมีผลทันทีโดยไม่ต้องติดตั้งใหม่ — uv sync/pip install -e . ทำแบบนี้ให้อยู่แล้ว เพราะ uv sync ติดตั้งตัวโปรเจกต์เองในโหมด editable โดยดีฟอลต์ เมื่อพร้อมแจกจ่าย uv build จะสร้างทั้ง source distribution และ wheel:
$ uv buildBuilding source distribution...Building wheel from source distribution...Successfully built dist/mypackage-0.1.0.tar.gzSuccessfully built dist/mypackage-0.1.0-py3-none-any.whl
$ ls -la dist/-rw-r--r-- mypackage-0.1.0-py3-none-any.whl-rw-r--r-- mypackage-0.1.0.tar.gzuv publish อัปโหลด artifact เหล่านั้นขึ้น index (PyPI โดยดีฟอลต์) — ไม่ได้รันจริงที่นี่ เพราะจะเป็นการเผยแพร่ package จริง:
$ uv publish --helpUpload distributions to an index
Usage: uv publish [OPTIONS] [FILES]...
Arguments: [FILES]... Paths to the files to upload. Accepts glob expressions [default: dist/*]
Options: -t, --token <TOKEN> The token for the upload [env: UV_PUBLISH_TOKEN] --trusted-publishing <TRUSTED_PUBLISHING> Configure trusted publishing [possible values: automatic, always, never] --check-url <CHECK_URL> Check an index URL for existing files to skip duplicate uploadsuv publish --token $UV_PUBLISH_TOKEN (PyPI API token หรือ --trusted-publishing automatic จาก CI บน GitHub Actions โดยไม่ต้องมี token เลย) จะอัปโหลดทุกอย่างใน dist/* ในขั้นตอนเดียว — ไม่ต้องมี twine upload แยกอีกแล้ว สำหรับฝึกซ้อม ให้ publish ไปยัง TestPyPI ก่อนด้วย --publish-url https://test.pypi.org/legacy/
uv.lock เทียบกับ requirements.txt — อีกครั้งในระดับ package
หัวข้อที่มีชื่อว่า “uv.lock เทียบกับ requirements.txt — อีกครั้งในระดับ package”บทเรียน venv-pip พูดถึง uv.lock สำหรับแอปพลิเคชัน; สำหรับ library อย่าง mypackage, uv.lock ก็ยังมีอยู่ (uv sync เขียนมันให้) แต่มัน lock development environment — เวอร์ชันที่คุณทดสอบด้วย — ไม่ใช่สิ่งที่ผู้ใช้ที่ติดตั้ง mypackage จาก PyPI จะได้รับ ซึ่งถูกกำหนดโดย version range ใน dependencies = [...] เท่านั้น เมื่อ tool ปลายทางต้องการไฟล์รูปแบบ requirements.txt ธรรมดาแทน uv.lock, uv export จะแปลงให้:
$ uv export --no-hashes --no-devResolved 20 packages in 3ms# This file was autogenerated by uv via the following command:# uv export --no-hashes --no-dev-e .anyio==4.15.1 # via httpxcertifi==2026.7.22 # via # httpcore # httpx
$ uv export --no-dev# (default: WITH hashes)anyio==4.15.1 \ --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ ...uv export ดีฟอลต์ให้รวม hash มาด้วย — ตรงข้ามกับดีฟอลต์ของ pip freeze ที่รันด้วยมือ และนี่คือเหตุผลที่ requirements.txt ที่ export จากโปรเจกต์ที่ใช้ uv เชื่อถือได้ในแบบที่ไฟล์ที่ maintain ด้วยมือมักไม่เป็น
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”pyproject.toml มีหน้าที่อิสระสามอย่างอยู่ใต้หลังคาเดียวกัน: [build-system] บอกทุก tool (“จะเปลี่ยน source tree นี้ให้เป็น artifact ที่ติดตั้งได้อย่างไร”) ว่าต้องเรียก backend ไหน; [project] คือ metadata ที่ package index และ installer อ่าน (name, version, dependencies, entry points); ตาราง [tool.*] คือ configuration แยก namespace สำหรับ development tool ตัวไหนก็ตามที่คุณรันกับ tree เดียวกันนี้ build backend ไม่เคยอ่าน [tool.*] และ ruff/mypy ก็ไม่เคยอ่าน [project.dependencies] — ไฟล์ถูกแชร์ร่วมกัน แต่แต่ละ tool มองแค่ตารางของตัวเองเท่านั้น
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| src-layout | ป้องกันการ import โค้ดที่ยังไม่ได้ติดตั้งโดยบังเอิญตอน test | มี directory level เพิ่มขึ้นหนึ่งชั้นเทียบกับ flat layout |
[dependency-groups] สำหรับ dev tool | ไม่เคยถูกส่งไปกับ wheel ที่เผยแพร่ ติดตั้งแยกได้ (--group lint) | ต้องเรียนรู้ตารางเพิ่ม แยกจาก optional-dependencies |
[tool.*] config รวมศูนย์ใน pyproject.toml | ไฟล์เดียว แหล่งข้อมูลเดียว diff ดูง่ายตอน review | tool เก่าบางตัว (flake8) ยังปฏิเสธที่จะอ่าน pyproject.toml เลย |
uv build + uv publish | เครื่องมือเดียว environment ที่รู้จัก lockfile เดียว ครอบคลุมทั้ง lifecycle | เป็น workflow ใหม่กว่า — tutorial ที่มีอยู่ส่วนใหญ่ยังแสดง python -m build + twine |
license = "MIT" (SPDX string) | อ่านด้วยเครื่องได้ ไม่กำกวม ตรงตาม PEP 639 | รูปแบบ table เก่า { text = "MIT" } ถูก deprecate แล้วและบาง tool จะเตือน |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
setup.pyยังคือวิธี configure package สมัยใหม่”distutilsถูกถอดออกใน Python 3.12 แล้ว; ต้องมีpyproject.toml[build-system]+ backend (hatchling,setuptools,uv_build) แล้ว และsetup.pyถ้ามีอยู่ก็แค่เป็น compatibility shim - “
[project.optional-dependencies]กับ[dependency-groups]คือสิ่งเดียวกันแค่ syntax ต่างกัน” Optional dependencies คือ extra ที่ติดตั้งได้ของ package ที่เผยแพร่ (pip install mypackage[postgres]); dependency groups คือ dev tooling เฉพาะ repo ที่ไม่เคยถูกส่งไปกับ wheel มาตรฐานแยกกันโดย PEP 735 - “
license = { text = "MIT" }คือ syntax สมัยใหม่ที่ถูกต้อง” รูปแบบ table นั้นถูก deprecate ภายใต้ PEP 639 แล้ว รูปแบบปัจจุบันคือ SPDX string เปล่า ๆlicense = "MIT" - “
uv.lockของ library จะ pin สิ่งที่ผู้ใช้ของคุณได้รับ” มัน pin แค่ development/test environment ของคุณเอง เวอร์ชัน dependency ที่ติดตั้งจริงของ library ที่เผยแพร่แล้วคือสิ่งที่ตรงกับ version range ในdependencies = [...]ตอนที่ผู้ใช้ปลายทางติดตั้ง - “wheel กับ source distribution (sdist) เป็น output ของ
uv buildที่ใช้แทนกันได้” wheel คือ artifact ที่ build ไว้ล่วงหน้าพร้อมติดตั้ง; sdist คือ source tree ที่บรรจุไว้เพื่อ build ทีหลัง — installer จะเลือก wheel ที่เข้ากันได้ก่อนเสมอ และ fallback ไป build จาก sdist ก็ต่อเมื่อไม่มี wheel ให้เลือก
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ pyproject.toml ที่ generate มา ให้ตรวจ:
license = { text = "MIT" }— รูปแบบ table ที่ deprecate แล้ว ควรเป็น SPDX stringlicense = "MIT"(PEP 639)- ส่วน
[tool.setuptools](packages,package-dir) ที่หลงเหลืออยู่ในโปรเจกต์ที่[build-system]ระบุ backend คนละตัว (hatchling,uv_build) — boilerplate ที่ copy-paste มาซึ่ง backend ที่ใช้งานจริงจะเมินเฉยแบบเงียบ ๆ ทำให้เข้าใจผิดว่า package discovery ถูก configure ไว้แล้ว - Runtime dependency (
requests,httpx) ถูกใส่ไว้แค่ใน[dependency-groups] devแทนที่จะเป็น[project] dependencies— package จะ import error สำหรับ installer จริงทุกตัว แต่ test ผ่านในเครื่องเพราะ dev group บังเอิญมีมันด้วย pre-commithook ที่รันruff formatแต่ไม่รันruff check(หรือกลับกัน) — formatting กับ linting เป็น subcommand คนละตัวของ ruff; hook ที่ format อย่างเดียวจะปล่อยให้ bug ประเภทF401/B006merge เข้าไปโดยไม่ถูกจับdependencies = ["some-lib"]ที่ไม่มี version constraint เลยใน library ที่ตั้งใจให้คนอื่น depend on — floor ที่ไม่ถูก constrain ทำให้ release ที่ breaking ของsome-libในอนาคตทำให้ผู้ใช้ทุกคนพังแบบเงียบ ๆ
🐛 หา bug ในโค้ดนี้
[build-system]requires = ["hatchling"]build-backend = "hatchling.build"
[project]name = "mypackage"version = "0.1.0"license = { text = "MIT" }dependencies = [ "httpx",]
[tool.setuptools]packages = ["mypackage"]
[tool.pytest.ini_options]testpaths = ["tests"]เฉลย
มีสอง bug license = { text = "MIT" } เป็นรูปแบบ table ของ PEP 639 ที่ deprecate แล้ว — ควรเป็น SPDX string เปล่า ๆ license = "MIT" ไม่งั้น linter หรือ uv build จะเตือน สอง [tool.setuptools] configure build backend ที่ไฟล์นี้ไม่ได้ใช้เลย — [build-system] ระบุ hatchling ดังนั้น key packages ของ setuptools จึงถูกเมินเฉยแบบเงียบ ๆ อาจเป็นเพราะ copy-paste มาจาก template ที่ใช้ backend คนละตัว มันทำให้เข้าใจผิดว่า package discovery ถูก configure ไว้อย่างชัดเจน ทั้งที่ hatchling กำลังใช้กฎ auto-discovery ของตัวเอง (ที่ต่างออกไป) ปัญหาเล็กที่สาม httpx ไม่มี version constraint เลย (httpx>=0.27 จะปลอดภัยกว่าสำหรับ library ที่เผยแพร่) ทำให้ release ที่ breaking ของ httpx ในอนาคตกระทบผู้ใช้ทุกคนโดยไม่มีคำเตือน