Bab 45 — Pengemasan: Dari Folder Kode Menjadi Paket

Seri A1 — Rekayasa Perangkat Lunak Profesional

CatatanDi bab ini, Anda akan:
  • Menata proyek dengan struktur package bertata letak src
  • Menulis pyproject.toml, “kartu identitas” paket
  • Membangun paket dengan python -m build dan memeriksanya dengan twine check
  • Memasang paket ke lingkungan baru dan memakai perintah terminalnya
  • Memahami alur publikasi (trusted publishing) tanpa menjalankannya

Semua skrip bab ini ada di folder kode/bab45/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Di Bab 44, rekap nilai kayu kita sudah cepat. Tetapi kodenya masih berupa berkas lepas. Bila rekan kerja ingin memakainya, Anda harus mengirim berkas satu per satu, dan mereka bingung versi mana yang terbaru. Bayangkan Anda menjual bibit: bibit lepas di karung itu merepotkan, sedangkan bibit dalam kemasan berlabel (nama, versi, cara tanam) mudah dikirim dan dipercaya. Paket Python adalah kemasan itu: kode ditambah label (metadata) yang dipahami alat seperti pip.

Dari folder kode lewat build menjadi sdist dan wheel, lalu diperiksa dengan twine check.

Struktur folder paket

01_struktur.py
# Bab 45 - Struktur folder paket (tata letak "src")
from pathlib import Path

akar = Path(__file__).parent / "proyek_paket"
print(akar.name + "/")
for berkas in sorted(akar.rglob("*"), key=lambda p: p.as_posix().lower()):
    if "__pycache__" in berkas.parts or "_tmp" in berkas.parts or berkas.suffix == ".egg-info":
        continue
    kedalaman = len(berkas.relative_to(akar).parts)
    tanda = "/" if berkas.is_dir() else ""
    print("  " * kedalaman + berkas.name + tanda)
proyek_paket/
  pyproject.toml
  README.md
  src/
    pantau_lapangan/
      __init__.py
      cli.py
      hitung.py
  tests/
    test_hitung.py

Ini disebut tata letak src: kode paket ada di dalam src/pantau_lapangan/, terpisah dari tests/. Keuntungannya: saat menguji, Anda menguji paket yang benar-benar diimpor seperti pengguna, bukan folder kerja yang kebetulan ada di sekitar. Tiap bagian:

  • pyproject.toml: kartu identitas dan cara membangun paket.
  • README.md: penjelasan untuk pengguna; ikut menjadi halaman utama paket.
  • __init__.py: tanda bahwa folder itu sebuah package, dan tempat memilih apa yang boleh diimpor orang.
  • cli.py: antarmuka perintah terminal.

pyproject.toml: kartu identitas paket

Berkas pyproject.toml memakai format TOML (teks sederhana berbentuk kunci = nilai). Isinya:

[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[project]
name = "pantau-lapangan-contoh"
version = "0.1.0"
description = "Paket contoh dari buku: rekap nilai kayu per plot dan jenis"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
dependencies = []

[project.optional-dependencies]
cepat = ["numpy>=2"]
uji = ["pytest>=8"]

[project.scripts]
pantau-lapangan = "pantau_lapangan.cli:main"

(Potongan di atas disingkat; berkas aslinya ada di zip dan juga menambah classifiers dan pencarian paket di src.) Python punya pembaca TOML bawaan, tomllib:

02_pyproject.py
# Bab 45 - Membaca pyproject.toml dengan tomllib (bawaan Python 3.11+)
import tomllib
from pathlib import Path

isi = tomllib.loads((Path(__file__).parent / "proyek_paket" / "pyproject.toml").read_text(encoding="utf-8"))

proyek = isi["project"]
print("nama paket     :", proyek["name"])
print("versi          :", proyek["version"])
print("butuh Python   :", proyek["requires-python"])
print("dependensi     :", proyek["dependencies"])
print("tambahan opsional:", sorted(proyek["optional-dependencies"]))
print("perintah terminal:", proyek["scripts"])
print("alat build     :", isi["build-system"]["requires"], "->", isi["build-system"]["build-backend"])
nama paket     : pantau-lapangan-contoh
versi          : 0.1.0
butuh Python   : >=3.12
dependensi     : []
tambahan opsional: ['cepat', 'uji']
perintah terminal: {'pantau-lapangan': 'pantau_lapangan.cli:main'}
alat build     : ['setuptools>=77'] -> setuptools.build_meta

Arti bagian pentingnya:

Bagian Fungsi
[build-system] alat yang dipakai untuk membangun paket (di sini setuptools)
name, version nama dan nomor versi paket
requires-python versi Python minimal
dependencies paket lain yang wajib ada (di sini kosong)
optional-dependencies tambahan opsional, dipasang dengan pip install "paket[cepat]"
[project.scripts] membuat perintah terminal pantau-lapangan yang memanggil fungsi main
CatatanINGAT

Nama paket di atas sengaja memakai akhiran -contoh supaya tidak bentrok dengan paket sungguhan. Sebelum memberi nama paket Anda sendiri, periksa dulu apakah namanya sudah dipakai di PyPI (gudang paket Python). Nomor versi mengikuti pola MAJOR.MINOR.PATCH: naikkan PATCH untuk perbaikan kecil, MINOR untuk fitur baru yang kompatibel, MAJOR bila ada perubahan yang bisa merusak kode pengguna.

Memakai paket langsung dari src

Sebelum dipasang, paket bisa dicoba dengan menaruh src ke jalur impor Python:

03_impor_dari_src.py
# Bab 45 - Memakai paket langsung dari folder src (belum dipasang)
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "proyek_paket" / "src"))

import pantau_lapangan

pohon = [
    {"jenis": "Jati", "plot": "A", "diameter_cm": 32.5, "tinggi_m": 18.0},
    {"jenis": "Meranti", "plot": "A", "diameter_cm": 45.2, "tinggi_m": 22.0},
    {"jenis": "Jati", "plot": "B", "diameter_cm": 28.0, "tinggi_m": 15.0},
]
print("versi (belum dipasang):", pantau_lapangan.__version__)
for kunci, nilai in pantau_lapangan.nilai_per_kelompok(pohon).items():
    print(kunci, f"Rp {nilai:,.0f}")
versi (belum dipasang): belum-dipasang
('A', 'Jati') Rp 2,986,474
('A', 'Meranti') Rp 4,412,646
('B', 'Jati') Rp 1,847,255

Perhatikan versinya: belum-dipasang. Di __init__.py, versi dibaca dari metadata paket yang terpasang (importlib.metadata), jadi satu-satunya sumber nomor versi adalah pyproject.toml. Bila belum dipasang, kita menampilkan nilai pengganti, bukan error.

Membangun: sdist dan wheel

Perintah python -m build menghasilkan dua berkas di folder dist/:

  • sdist (source distribution, .tar.gz): arsip kode sumber.
  • wheel (.whl): paket jadi yang siap dipasang cepat oleh pip.

Kita membangun di folder sementara _tmp/ supaya folder kerja tetap bersih:

04_build.py
# Bab 45 - Membangun paket (python -m build) di folder sementara _tmp/
import shutil
import subprocess
import sys
from pathlib import Path

akar = Path(__file__).parent
sumber = akar / "proyek_paket"
kerja = akar / "_tmp" / "proyek"

shutil.rmtree(akar / "_tmp", ignore_errors=True)
shutil.copytree(sumber, kerja, ignore=shutil.ignore_patterns("__pycache__", "*.egg-info", "_tmp"))

# --no-isolation: memakai setuptools yang sudah terpasang, jadi tidak perlu internet.
# Tanpa opsi itu (cara biasa), "build" membuat lingkungan terpisah dan mengunduh alat build.
hasil = subprocess.run(
    [sys.executable, "-m", "build", "--no-isolation", str(kerja)],
    capture_output=True, text=True, encoding="utf-8",
)
print("kode keluar build:", hasil.returncode)
for baris in hasil.stdout.splitlines():
    if baris.startswith("Successfully built"):
        print(baris)
print("isi folder dist:")
for berkas in sorted((kerja / "dist").iterdir()):
    print(" ", berkas.name)
kode keluar build: 0
Successfully built pantau_lapangan_contoh-0.1.0.tar.gz and pantau_lapangan_contoh-0.1.0-py3-none-any.whl
isi folder dist:
  pantau_lapangan_contoh-0.1.0-py3-none-any.whl
  pantau_lapangan_contoh-0.1.0.tar.gz
PeringatanAWAS

Perintah normalnya hanya python -m build. Perintah itu membuat lingkungan terisolasi lalu mengunduh alat build dari internet. Skrip kita memakai --no-isolation agar berjalan tanpa internet dengan setuptools yang sudah terpasang. Di komputer Anda cukup python -m build. (Anda perlu memasang alat build dahulu dengan pip install build.)

twine check dan isi wheel

twine check memeriksa apakah metadata paket valid dan README-nya dapat ditampilkan benar di PyPI. Ia hanya memeriksa; tidak mengunggah apa pun.

05_twine_check.py
# Bab 45 - twine check: memeriksa metadata paket (TIDAK mengunggah apa pun)
import os
import re
import subprocess
import sys
from pathlib import Path

dist = Path(__file__).parent / "_tmp" / "proyek" / "dist"
berkas = sorted(str(p) for p in dist.iterdir())

# COLUMNS lebar dan NO_COLOR agar keluaran twine tidak terpotong atau berwarna
env = dict(os.environ, COLUMNS="400", NO_COLOR="1")
hasil = subprocess.run(
    [sys.executable, "-m", "twine", "check", *berkas],
    capture_output=True, text=True, encoding="utf-8", env=env,
)
teks = re.sub(r"\s+", " ", hasil.stdout)
for nama, status in re.findall(r"Checking \S*?([\w.\-]+\.(?:whl|tar\.gz)): (\w+)", teks):
    print(f"{nama}: {status}")
print("kode keluar twine check:", hasil.returncode)
pantau_lapangan_contoh-0.1.0-py3-none-any.whl: PASSED
pantau_lapangan_contoh-0.1.0.tar.gz: PASSED
kode keluar twine check: 0

Wheel ternyata hanya berkas zip. Mari mengintip isinya:

06_isi_wheel.py
# Bab 45 - Mengintip isi berkas wheel (sebenarnya sebuah zip)
import zipfile
from pathlib import Path

dist = Path(__file__).parent / "_tmp" / "proyek" / "dist"
wheel = next(dist.glob("*.whl"))
print("nama wheel:", wheel.name)

with zipfile.ZipFile(wheel) as z:
    for nama in sorted(z.namelist()):
        print(" ", nama)
    metadata = z.read(next(n for n in z.namelist() if n.endswith("METADATA"))).decode("utf-8")

print("--- sebagian METADATA ---")
for baris in metadata.splitlines()[:8]:
    print(baris)
nama wheel: pantau_lapangan_contoh-0.1.0-py3-none-any.whl
  pantau_lapangan/__init__.py
  pantau_lapangan/cli.py
  pantau_lapangan/hitung.py
  pantau_lapangan_contoh-0.1.0.dist-info/METADATA
  pantau_lapangan_contoh-0.1.0.dist-info/RECORD
  pantau_lapangan_contoh-0.1.0.dist-info/WHEEL
  pantau_lapangan_contoh-0.1.0.dist-info/entry_points.txt
  pantau_lapangan_contoh-0.1.0.dist-info/top_level.txt
--- sebagian METADATA ---
Metadata-Version: 2.4
Name: pantau-lapangan-contoh
Version: 0.1.0
Summary: Paket contoh dari buku: rekap nilai kayu per plot dan jenis
License-Expression: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.12

Yang masuk hanya kode paket (pantau_lapangan/) dan folder dist-info berisi metadata. Folder tests/ tidak ikut, karena kita memang hanya mencari paket di src. Baris METADATA memuat nama, versi, ringkasan, dan Requires-Python yang kita tulis di pyproject.toml.

Memasang dan memakai

Bukti terbaik sebuah paket berfungsi adalah memasangnya di tempat bersih, seolah Anda pengguna lain. Skrip berikut membuat lingkungan virtual baru, memasang wheel, lalu menjalankan perintah terminalnya:

07_pasang_dan_pakai.py
# Bab 45 - Proyek bersambung A1: memasang wheel ke lingkungan virtual BARU lalu memakainya
import subprocess
import sys
from pathlib import Path

akar = Path(__file__).parent / "_tmp"
wheel = next((akar / "proyek" / "dist").glob("*.whl"))
venv = akar / "venv_uji"
bin_dir = venv / ("Scripts" if sys.platform == "win32" else "bin")
exe = ".exe" if sys.platform == "win32" else ""

subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
python_baru = str(bin_dir / f"python{exe}")

# --no-index --no-deps: tidak ke internet, dan paket kita memang tanpa dependensi
pasang = subprocess.run(
    [python_baru, "-m", "pip", "install", "--no-index", "--no-deps", "--quiet", str(wheel)],
    capture_output=True, text=True,
)
print("pasang berhasil:", pasang.returncode == 0)

versi = subprocess.run(
    [python_baru, "-c", "import pantau_lapangan as p; print(p.__version__)"],
    capture_output=True, text=True,
)
print("versi terpasang:", versi.stdout.strip())

cli = subprocess.run([str(bin_dir / f"pantau-lapangan{exe}"), "--jumlah", "1000"],
                     capture_output=True, text=True, encoding="utf-8")
print("--- keluaran perintah pantau-lapangan --jumlah 1000 ---")
print(cli.stdout.rstrip())
pasang berhasil: True
versi terpasang: 0.1.0
--- keluaran perintah pantau-lapangan --jumlah 1000 ---
12 kelompok dari 1000 pohon
  plot A Jati     Rp 310,014,243
  plot A Mahoni   Rp 191,426,619
  plot A Meranti  Rp 167,648,188

Perintah pantau-lapangan tersedia karena bagian [project.scripts] tadi. Versi terpasang terbaca 0.1.0 dari metadata, bukan lagi belum-dipasang. Opsi --no-index --no-deps hanya kita pakai agar skrip tak butuh internet.

Menguji paket

08_uji_paket.py
# Bab 45 - Menjalankan tes paket dengan pytest (terhadap kode di folder src)
import os
import re
import subprocess
import sys
from pathlib import Path

proyek = Path(__file__).parent / "proyek_paket"
env = dict(os.environ, PYTHONPATH=str(proyek / "src"))

hasil = subprocess.run(
    [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", str(proyek / "tests")],
    capture_output=True, text=True, encoding="utf-8", env=env, cwd=str(proyek),
)
ringkas = re.search(r"(\d+) passed", hasil.stdout)
print("tes lulus:", ringkas.group(1) if ringkas else "tidak ada")
print("kode keluar pytest:", hasil.returncode)
tes lulus: 2
kode keluar pytest: 0

Dua tes lulus. Kita menjalankan pytest terhadap kode di src lewat PYTHONPATH. Di proyek sungguhan, Anda biasanya memasang paket dalam mode terkembang (pip install -e .) lalu menjalankan pytest.

Alur publikasi: trusted publishing (tidak dijalankan)

Untuk membagikan paket ke semua orang, Anda mengunggahnya ke PyPI. Dokumentasi resmi (packaging.python.org, dibaca 2026-10-10) menganjurkan Trusted Publishing: alih-alih menyimpan token rahasia, PyPI mempercayai alur kerja GitHub Actions tertentu milik Anda. Ringkasnya:

  1. Di PyPI, daftarkan “publisher tepercaya”: repositori GitHub dan nama lingkungan (environment) pypi.
  2. Di GitHub, buat alur kerja yang membangun paket, lalu menerbitkannya hanya saat ada tag rilis.
  3. Alur kerja diberi izin id-token: write supaya GitHub bisa membuktikan identitasnya ke PyPI.

Potongan alur kerja dari dokumentasi resmi (disederhanakan; hanya bagian menerbitkan):

publish-to-pypi:
  if: startsWith(github.ref, 'refs/tags/')   # hanya saat tag rilis
  needs:
    - build
  runs-on: ubuntu-latest
  environment:
    name: pypi
    url: https://pypi.org/p/<nama-paket>
  permissions:
    id-token: write   # wajib untuk trusted publishing
  steps:
    - name: Download all the dists
      uses: actions/download-artifact@v6
      with:
        name: python-package-distributions
        path: dist/
    - name: Publish distribution to PyPI
      uses: pypa/gh-action-pypi-publish@release/v1

Kode ini tidak dijalankan saat buku dibuat karena mengunggah ke PyPI mengubah dunia luar dan tidak boleh dilakukan untuk contoh buku, jadi keluarannya tidak ditampilkan. Kami tidak mengunggah paket contoh ini ke PyPI maupun TestPyPI.

Proyek Bersambung A1: rekap nilai kayu menjadi paket

Skrip 04 sampai 08 di atas sekaligus adalah langkah proyek: fungsi nilai_per_kelompok dari Bab 44 kini ada di paket pantau-lapangan-contoh versi 0.1.0, lolos twine check, terpasang di lingkungan bersih, bisa dipanggil lewat perintah pantau-lapangan, dan teruji (2 tes lulus). Versi paket ini sengaja tidak memakai numpy (cukup satu kali lewat data dengan dict), dan numpy ditaruh sebagai tambahan opsional cepat. Itu keputusan desain: paket yang punya sedikit dependensi lebih mudah dipasang. Di Bab 46, kita menata ulang arsitekturnya.

Latihan

  1. Ubah version di pyproject.toml (salinan Anda) menjadi 0.1.1, bangun ulang, dan lihat nama berkas di dist/.
  2. Tambahkan perintah terminal kedua di [project.scripts] yang memanggil fungsi lain. Pasang ulang dan cobalah.
  3. Hapus README.md dari salinan proyek lalu jalankan python -m build. Apa yang terjadi?
  4. Tantangan: tambahkan [project.urls] (misalnya Homepage) dan opsi --jumlah bernilai tak valid (--jumlah abc) pada CLI; amati pesan galat argparse yang sudah ramah.

Ringkasan Bab

  • Paket = kode + metadata. Tata letak src memisahkan kode paket dari tes.
  • pyproject.toml adalah satu berkas konfigurasi: build, nama, versi, dependensi, perintah terminal.
  • python -m build menghasilkan sdist dan wheel; twine check memeriksa metadata tanpa mengunggah.
  • Buktikan paket dengan memasangnya di lingkungan bersih.
  • Publikasi resmi memakai Trusted Publishing dari GitHub Actions; di buku ini hanya dijelaskan, tidak dijalankan.

Selanjutnya, di Bab 46: pola desain dan arsitektur, menata kode agar tetap rapi saat aplikasi membesar.