Bab 34 — Validasi Data dan Konfigurasi: Penjaga Pintu dengan Pydantic

Seri I4 — Bahasa Python Tingkat Lanjut

CatatanDi bab ini, Anda akan:
  • Memahami mengapa data dari luar harus divalidasi, dan batas dataclass
  • Membuat model Pydantic: validasi, konversi tipe, dan pesan galat
  • Menambah aturan dengan Field, field_validator, dan model_validator
  • Membaca pengaturan aplikasi dari variabel lingkungan dan .env (pydantic-settings)
  • Memvalidasi data lapangan lima sektor sekaligus: proyek Seri I4

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

Di Bab 33, type hint berfungsi sebagai label yang dibaca alat pemeriksa. Tetapi saat program berjalan, label itu tidak menjaga apa-apa. Padahal data lapangan sering datang dari luar: formulir, berkas CSV, JSON dari API, ketikan petugas. Isinya bisa salah ketik, kosong, atau di luar akal sehat (pH air 17, diameter pohon negatif).

Perlu penjaga pintu: pemeriksa yang bekerja saat program berjalan, menolak data buruk dengan pesan jelas, dan merapikan data yang masih bisa diterima. Di Python, penjaga pintu yang paling populer adalah Pydantic.

%%{init: {"flowchart": {"diagramPadding": 40}}}%%
flowchart LR
  A["Data mentah<br/>(dict, JSON, .env)"] --> B["Model Pydantic<br/>(tipe + aturan)"]
  B -- "lolos" --> C["Objek bersih<br/>dengan tipe benar"]
  B -- "tidak lolos" --> D["ValidationError<br/>dengan pesan jelas"]

Pydantic sebagai penjaga pintu: data mentah diperiksa, lalu menjadi objek bersih atau ditolak dengan pesan galat.

dataclass rapi, tetapi tidak menjaga

Di Bab 16 Anda mengenal dataclass: cara ringkas membuat kelas penyimpan data. Lihat apa yang terjadi bila isinya tidak masuk akal:

01_dataclass_tak_memeriksa.py
# Bab 34 - dataclass rapi, tetapi tidak memeriksa isi
from dataclasses import dataclass


@dataclass
class Pohon:
    jenis: str
    diameter_cm: float
    sehat: bool


bagus = Pohon("Jati", 32.5, True)
print(bagus)

kacau = Pohon("Jati", "tiga puluh", "mungkin")   # tidak ada protes
print(kacau)
print(type(kacau.diameter_cm).__name__)
Pohon(jenis='Jati', diameter_cm=32.5, sehat=True)
Pohon(jenis='Jati', diameter_cm='tiga puluh', sehat='mungkin')
str

Objek kacau dibuat tanpa protes: diameter berisi teks 'tiga puluh', sehat berisi 'mungkin'. Tipenya str, padahal kita menulis float. dataclass hanya merapikan penulisan kelas, tidak memeriksa isi. Kesalahan baru terasa nanti, mungkin jauh dari sumbernya, ketika angka itu dihitung.

Model Pydantic

Pydantic adalah pustaka validasi data yang memakai type hint sebagai aturan. Versi yang terpasang di komputer penulis berseri utama 2 (skrip mencetak “versi utama” saja, karena nomor lengkapnya berubah dari waktu ke waktu). Buku ini memakai gaya Pydantic seri 2.

02_model_pydantic.py
# Bab 34 - Model Pydantic: memeriksa dan mengubah tipe
import pydantic
from pydantic import BaseModel

print("Pydantic versi utama:", pydantic.VERSION.split(".")[0])


class Pohon(BaseModel):
    jenis: str
    diameter_cm: float
    sehat: bool = True          # nilai bawaan


p = Pohon(jenis="Jati", diameter_cm="32.5", sehat="true")   # teks diubah menjadi angka dan bool
print(p)
print(type(p.diameter_cm).__name__, type(p.sehat).__name__)
print(p.model_dump())

p2 = Pohon(jenis="Meranti", diameter_cm=45)   # int menjadi float
print(p2.diameter_cm, p2.sehat)
Pydantic versi utama: 2
jenis='Jati' diameter_cm=32.5 sehat=True
float bool
{'jenis': 'Jati', 'diameter_cm': 32.5, 'sehat': True}
45.0 True
  • Model dibuat dengan kelas turunan BaseModel, dan setiap atribut diberi tipe. sehat: bool = True punya nilai bawaan.
  • Pydantic mengonversi bila masuk akal: teks "32.5" menjadi float 32.5, teks "true" menjadi True, dan bilangan bulat 45 menjadi 45.0.
  • Hasilnya objek dengan tipe yang benar: float dan bool (bukan str).
  • model_dump() mengubah objek kembali menjadi dict biasa.

Di sini ada pelajaran kecil. Saat saya mencoba mengisi sehat="ya" (kata Indonesia untuk “ya”), Pydantic menolaknya (bool_parsing): ia tidak mengenal kata itu. Yang ia kenali adalah bentuk baku seperti "true". Konversi Pydantic itu terbatas dan terdokumentasi, bukan menebak.

Pesan galat yang jelas

Bila data tidak bisa diterima, Pydantic melempar ValidationError yang menjelaskan semua masalah sekaligus, bukan hanya yang pertama.

03_pesan_galat.py
# Bab 34 - Pesan galat yang jelas
from pydantic import BaseModel, ValidationError


class Pohon(BaseModel):
    jenis: str
    diameter_cm: float
    sehat: bool


try:
    Pohon(jenis="Jati", diameter_cm="tiga puluh", sehat="mungkin")
except ValidationError as e:
    print("jumlah masalah:", e.error_count())
    for masalah in e.errors(include_url=False, include_input=False):
        print(masalah["loc"], "|", masalah["type"], "|", masalah["msg"])

try:
    Pohon(jenis="Jati")
except ValidationError as e:
    for masalah in e.errors(include_url=False, include_input=False):
        print(masalah["loc"], "|", masalah["type"], "|", masalah["msg"])
jumlah masalah: 2
('diameter_cm',) | float_parsing | Input should be a valid number, unable to parse string as a number
('sehat',) | bool_parsing | Input should be a valid boolean, unable to interpret input
('diameter_cm',) | missing | Field required
('sehat',) | missing | Field required

Pada percobaan pertama, ada dua masalah: diameter_cm tidak bisa dibaca sebagai angka (float_parsing) dan sehat tidak bisa dibaca sebagai ya/tidak (bool_parsing). Pada percobaan kedua, dua kolom wajib tidak diisi (missing: “Field required”). Setiap masalah punya:

  • loc: letak masalah (nama kolom),
  • type: kategori galat yang stabil dan bisa diproses program,
  • msg: kalimat penjelas untuk manusia.

Opsi include_url=False dan include_input=False menghilangkan tautan dan salinan masukan dari tampilan, supaya keluaran ringkas dan stabil.

Aturan tambahan: Field dan validator

Tipe saja belum cukup. “Angka” belum berarti “masuk akal”. Aturan tambahan bisa dipasang dengan Field (batas sederhana) dan validator (aturan buatan sendiri).

04_aturan_validasi.py
# Bab 34 - Aturan lewat Field dan validator
from typing import Literal

from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator


class Ikan(BaseModel):
    kolam: str = Field(min_length=1, max_length=3)
    jenis: Literal["lele", "nila"]
    jumlah: int = Field(gt=0)
    bobot_rata_g: float = Field(ge=1, le=5000)
    ph_air: float = Field(ge=0, le=14)

    @field_validator("kolam")
    @classmethod
    def huruf_besar(cls, nilai: str) -> str:
        return nilai.upper()          # sekaligus merapikan

    @model_validator(mode="after")
    def periksa_bobot_lele(self):
        if self.jenis == "lele" and self.bobot_rata_g > 2000:
            raise ValueError("bobot rata-rata lele melebihi 2000 g tidak masuk akal")
        return self


print(Ikan(kolam="a", jenis="lele", jumlah=800, bobot_rata_g=540.5, ph_air=7.2))

for data in [
    dict(kolam="B", jenis="gurame", jumlah=100, bobot_rata_g=300, ph_air=7),
    dict(kolam="B", jenis="nila", jumlah=0, bobot_rata_g=300, ph_air=15),
    dict(kolam="C", jenis="lele", jumlah=10, bobot_rata_g=3000, ph_air=7),
]:
    try:
        Ikan(**data)
    except ValidationError as e:
        for m in e.errors(include_url=False, include_input=False):
            print(m["loc"], "|", m["msg"])
        print("--")
kolam='A' jenis='lele' jumlah=800 bobot_rata_g=540.5 ph_air=7.2
('jenis',) | Input should be 'lele' or 'nila'
--
('jumlah',) | Input should be greater than 0
('ph_air',) | Input should be less than or equal to 14
--
() | Value error, bobot rata-rata lele melebihi 2000 g tidak masuk akal
--
  • Field(gt=0) berarti “lebih besar dari 0”, ge/le berarti “lebih besar sama dengan”/“lebih kecil sama dengan”, dan min_length/max_length membatasi panjang teks.
  • Literal["lele", "nila"] membatasi pilihan jenis ikan. "gurame" ditolak: “Input should be ‘lele’ or ‘nila’”.
  • @field_validator("kolam") memeriksa (dan boleh merapikan) satu kolom: nama kolam "a" menjadi "A".
  • @model_validator(mode="after") memeriksa gabungan kolom setelah semuanya valid: lele dengan bobot rata-rata di atas 2000 g ditolak, dan lokasi galatnya () (kosong) karena masalahnya milik seluruh model, bukan satu kolom.
PeringatanAWAS

Batas seperti bobot_rata_g <= 5000 atau “lele tidak lebih dari 2000 g” di skrip ini hanyalah angka contoh untuk latihan, bukan anjuran budidaya. Tentukan batas sebenarnya bersama penyuluh atau pembudidaya berpengalaman.

Pengaturan aplikasi: variabel lingkungan dan .env

Aplikasi punya pengaturan yang berbeda di tiap komputer: lokasi berkas, batas unduhan, mode uji, kunci layanan. Menaruhnya di dalam kode merepotkan dan, untuk kunci rahasia, berbahaya. Praktik umum: simpan di variabel lingkungan (environment variable, pengaturan di luar program) atau di berkas .env (daftar NAMA=nilai), dan baca saat program mulai.

Paket pydantic-settings membaca pengaturan ke dalam kelas yang divalidasi seperti model Pydantic. Skrip ini memakai nilai tiruan (tidak ada rahasia sungguhan) dan menulis berkas .env contoh ke _tmp/.

05_pengaturan_env.py
# Bab 34 - Pengaturan aplikasi dari variabel lingkungan dan berkas .env (nilai tiruan)
import os
from pathlib import Path

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

folder = Path(__file__).parent / "_tmp"
folder.mkdir(exist_ok=True)
berkas_env = folder / "contoh.env"
berkas_env.write_text("LAPANGAN_NAMA_UNIT=Kolam Contoh\nLAPANGAN_TOKEN_API=token-tiruan-bukan-rahasia\n"
                      "LAPANGAN_BATAS_UNDUH=5\n", encoding="utf-8")


class Pengaturan(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="LAPANGAN_", env_file=berkas_env, env_file_encoding="utf-8")

    nama_unit: str = "Tanpa nama"
    batas_unduh: int = 10
    token_api: SecretStr = SecretStr("")
    mode_uji: bool = False


s = Pengaturan()
print("dari .env       :", s.nama_unit, s.batas_unduh, s.mode_uji)
print("token (disamarkan):", s.token_api)
print("token (sengaja dibuka):", s.token_api.get_secret_value() == "token-tiruan-bukan-rahasia")

os.environ["LAPANGAN_BATAS_UNDUH"] = "25"          # variabel lingkungan mengalahkan .env
os.environ["LAPANGAN_MODE_UJI"] = "true"
s2 = Pengaturan()
print("env mengalahkan .env:", s2.batas_unduh, s2.mode_uji)

print("argumen langsung paling kuat:", Pengaturan(batas_unduh=1).batas_unduh)
dari .env       : Kolam Contoh 5 False
token (disamarkan): **********
token (sengaja dibuka): True
env mengalahkan .env: 25 True
argumen langsung paling kuat: 1
  • env_prefix="LAPANGAN_" berarti pengaturan batas_unduh dibaca dari LAPANGAN_BATAS_UNDUH. Awalan ini mencegah bentrok dengan variabel milik program lain.
  • Nilai dari .env otomatis dikonversi: "5" menjadi int 5.
  • SecretStr menyamarkan nilai saat dicetak (**********), supaya kunci tidak ikut bocor ke log. Nilai aslinya hanya terbuka lewat get_secret_value(), yang kita pakai di sini hanya untuk membandingkan.
  • Urutan kekuatan, sesuai dokumentasi pydantic-settings: argumen langsung > variabel lingkungan > berkas .env > nilai bawaan. Skrip membuktikannya: .env memberi 5, variabel lingkungan menimpanya menjadi 25, dan argumen Pengaturan(batas_unduh=1) mengalahkan semuanya.
TipTIPS

Berkas .env berisi pengaturan pribadi dan sering rahasia. Jangan simpan di Git (masukkan ke .gitignore, Bab 20), dan sediakan berkas contoh (.env.example) tanpa nilai rahasia. Di buku ini semua nilai tiruan.

JSON, daftar model, dan skema

06_json_dan_skema.py
# Bab 34 - JSON, daftar model, dan skema JSON
import json

from pydantic import BaseModel, TypeAdapter


class Rekaman(BaseModel):
    sektor: str
    unit: str
    nilai: float


mentah = '{"sektor": "kolam", "unit": "A", "nilai": "540.5"}'
r = Rekaman.model_validate_json(mentah)       # JSON teks -> objek tervalidasi
print(r)
print(r.model_dump_json())

daftar = TypeAdapter(list[Rekaman]).validate_python([
    {"sektor": "hutan", "unit": "H1", "nilai": 32.5},
    {"sektor": "kebun", "unit": "K2", "nilai": "245.5"},
])
print(len(daftar), daftar[1].nilai)

skema = Rekaman.model_json_schema()           # dasar dokumentasi API dan alat agent AI
print(json.dumps(skema, indent=2))
sektor='kolam' unit='A' nilai=540.5
{"sektor":"kolam","unit":"A","nilai":540.5}
2 245.5
{
  "properties": {
    "sektor": {
      "title": "Sektor",
      "type": "string"
    },
    "unit": {
      "title": "Unit",
      "type": "string"
    },
    "nilai": {
      "title": "Nilai",
      "type": "number"
    }
  },
  "required": [
    "sektor",
    "unit",
    "nilai"
  ],
  "title": "Rekaman",
  "type": "object"
}
  • model_validate_json memvalidasi teks JSON langsung ("540.5" menjadi angka). model_dump_json() mengubah objek kembali menjadi JSON.
  • TypeAdapter(list[Rekaman]) memvalidasi daftar model sekaligus.
  • model_json_schema() menghasilkan skema JSON: deskripsi baku bentuk datanya (kolom, tipe, mana yang wajib). Skema inilah dasar dokumentasi otomatis sebuah API.

Dataclass atau Pydantic?

dataclass Pydantic BaseModel
Memeriksa isi saat berjalan tidak ya
Mengonversi tipe ("32.5" ke 32,5) tidak ya
Pesan galat terperinci tidak ya
Bagian pustaka standar ya tidak (pustaka tambahan)
Cocok untuk data di dalam program yang sudah tepercaya data yang masuk dari luar

Aturan praktis: data yang melewati “perbatasan” (formulir, berkas, API, pengaturan) divalidasi dengan Pydantic; data internal yang sudah bersih boleh berupa dataclass.

Kaitan dengan FastAPI dan agent AI

Pemeriksaan di perbatasan ini menjadi dasar dua topik berikutnya dalam buku. FastAPI (Bab 36), kerangka kerja web, memakai Pydantic: paket FastAPI yang terpasang di komputer penulis mencantumkan pydantic sebagai ketergantungan (hasil pip show fastapi), dan di Bab 36 Anda akan melihat sendiri model Pydantic dipakai untuk memeriksa masukan API (di bab ini hal itu belum dijalankan). Pada pembahasan agent AI di Tingkat Advanced, skema serupa dipakai untuk menjelaskan bentuk data kepada model bahasa; bagian itu belum diuji di bab ini.

Proyek Bersambung I4: validasi data lapangan lima sektor

Data lapangan lima sektor punya bentuk berbeda: pohon punya diameter, kebun punya hasil, sawah punya gabah, kolam punya bobot dan pH, kandang punya bobot ternak. Kita buat lima model, lalu satu “pintu” yang memilih model dengan membaca kolom sektor (disebut discriminated union, gabungan bertanda).

07_proyek_validasi_lima_sektor.py
# Bab 34 - Proyek bersambung I4: validasi data lapangan lima sektor
from typing import Annotated, Literal

from pydantic import BaseModel, Field, TypeAdapter, ValidationError


class Hutan(BaseModel):
    sektor: Literal["hutan"]
    unit: str
    diameter_cm: float = Field(gt=0, le=300)


class Kebun(BaseModel):
    sektor: Literal["kebun"]
    unit: str
    hasil_kg: float = Field(ge=0)


class Sawah(BaseModel):
    sektor: Literal["sawah"]
    unit: str
    gabah_kg: float = Field(ge=0)


class Kolam(BaseModel):
    sektor: Literal["kolam"]
    unit: str
    bobot_rata_g: float = Field(gt=0)
    ph_air: float = Field(ge=0, le=14)


class Kandang(BaseModel):
    sektor: Literal["kandang"]
    unit: str
    bobot_kg: float = Field(gt=0)


Lapangan = Annotated[Hutan | Kebun | Sawah | Kolam | Kandang, Field(discriminator="sektor")]
pemeriksa = TypeAdapter(Lapangan)

mentah = [
    {"sektor": "hutan", "unit": "H1", "diameter_cm": "32.5"},
    {"sektor": "kebun", "unit": "K1", "hasil_kg": 245.5},
    {"sektor": "sawah", "unit": "S1", "gabah_kg": -10},
    {"sektor": "kolam", "unit": "A", "bobot_rata_g": 540.5, "ph_air": 7.2},
    {"sektor": "kolam", "unit": "B", "bobot_rata_g": 498, "ph_air": 17},
    {"sektor": "kandang", "unit": "D1", "bobot_kg": 412.5},
    {"sektor": "empang", "unit": "E1", "bobot_rata_g": 100},
    {"sektor": "kandang", "unit": "D2"},
]

sah, ditolak = [], []
for nomor, baris in enumerate(mentah, start=1):
    try:
        sah.append(pemeriksa.validate_python(baris))
    except ValidationError as e:
        pesan = e.errors(include_url=False, include_input=False)[0]
        ditolak.append((nomor, ".".join(str(x) for x in pesan["loc"]) or "sektor", pesan["msg"]))

print("sah:", len(sah), "| ditolak:", len(ditolak))
print("jenis objek sah:", sorted({type(o).__name__ for o in sah}))
for nomor, tempat, alasan in ditolak:
    print(f"baris {nomor}: {tempat} -> {alasan}")
sah: 4 | ditolak: 4
jenis objek sah: ['Hutan', 'Kandang', 'Kebun', 'Kolam']
baris 3: sawah.gabah_kg -> Input should be greater than or equal to 0
baris 5: kolam.ph_air -> Input should be less than or equal to 14
baris 7: sektor -> Input tag 'empang' found using 'sektor' does not match any of the expected tags: 'hutan', 'kebun', 'sawah', 'kolam', 'kandang'
baris 8: kandang.bobot_kg -> Field required

Delapan baris mentah masuk, empat lolos dan empat ditolak:

  • Baris 1: diameter_cm berupa teks "32.5", dikonversi dan diterima sebagai Hutan.
  • Baris 3: gabah_kg bernilai -10, ditolak karena harus >= 0.
  • Baris 5: pH 17, ditolak karena di atas 14.
  • Baris 7: sektor "empang" bukan salah satu dari lima, ditolak, dan pesannya menyebut pilihan yang sah.
  • Baris 8: kolom bobot_kg tidak ada (“Field required”).
  • Jenis objek yang lolos: Hutan, Kandang, Kebun, Kolam, masing-masing berbentuk berbeda tetapi semuanya sudah bertipe benar.

Data yang ditolak tidak membuat program berhenti: ia dicatat beserta alasannya, dan sisanya diproses. Itulah pola kerja yang aman untuk data lapangan yang kotor.

Latihan

  1. Buat model Ternak dengan kolom kandang: str, jenis: Literal["sapi", "kambing", "ayam"], umur_bulan: int (harus >= 0), dan bobot_kg: float (harus > 0). Coba dengan satu data benar dan satu data salah.
  2. Ubah 03_pesan_galat.py: berikan diameter_cm=-5 pada model yang punya Field(gt=0). Baca pesan galatnya.
  3. Di 05_pengaturan_env.py, tambahkan pengaturan folder_keluaran: str bernilai bawaan "hasil", lalu timpa lewat variabel lingkungan dan lewat .env. Amati mana yang menang.
  4. Tantangan: tambahkan model keenam Peralatan ke 07_proyek_validasi_lima_sektor.py (dengan sektor bernilai "alat"), masukkan ke gabungan, dan tambahkan dua baris mentah (satu benar, satu salah). Hitung ulang jumlah lolos dan ditolak.

Ringkasan Bab

  • Data dari luar harus divalidasi saat program berjalan; type hint saja tidak menjaga apa-apa.
  • dataclass tidak memeriksa isi; Pydantic BaseModel memeriksa, mengonversi, dan memberi pesan galat per kolom.
  • Aturan tambahan: Field (batas), field_validator (satu kolom), model_validator (gabungan kolom).
  • pydantic-settings membaca pengaturan dari variabel lingkungan dan .env; urutan kekuatan: argumen > lingkungan > .env > bawaan; SecretStr menyamarkan rahasia.
  • TypeAdapter, model_validate_json, dan model_json_schema memvalidasi daftar, JSON, dan menghasilkan skema.
  • Proyek I4: data lima sektor divalidasi lewat gabungan bertanda; empat lolos, empat ditolak dengan alasan.

Selanjutnya, di Bab 35: async dan concurrency, cara mengerjakan banyak pekerjaan menunggu sekaligus dan menutup Seri I4.