Skrip sekali jalan memang menyelesaikan pekerjaan hari ini — tetapi besok data mentah baru datang, dan minggu depan lagi. Pipeline adalah jawaban untuk pekerjaan yang berulang: rangkaian tahap bernama yang membaca sumber, memvalidasi, membersihkan, mentransformasi, lalu menuliskan hasil dengan cara yang sama setiap dijalankan. Seluruh bab section ini — membaca CSV, validasi JSON, pembersihan, transformasi, join — adalah komponennya; chapter ini merakitnya menjadi satu program utuh yang bisa dijalankan dari CLI, diamati lewat log, dan diuji dengan pytest.
Anatomi Enam Tahap #
Pipeline kita mengikuti pola ETL yang diperluas:
Setiap tahap adalah fungsi yang menerima data dan mengembalikan data — bentuk masukan dan keluaran yang konsisten inilah yang membuat tahap bisa disusun seperti pipa (pola komposisi dari chapter Function Composition). Bahan mentahnya adalah penjualan_mentah.csv berikut, yang menumpuk masalah dari semua chapter sebelumnya:
tanggal,sku,qty,harga
2025-06-13,P001,2,85000
2025-06-13,p005,3,15000
2025-06-13,P099,1,12000
2025-06-14,P002,1,45000
2025-06-14,P002,1,45000
2025-06-15,P001,2,85000
2025-06-15,P008,10,3500
2025-06-15,P003,,32000
2025-06-16,P005,5,Rp 15.000
2025-06-16,P004,1,78000
2025-06-17,P005,2,15000
2025-06-17,P010,1,250000Ada p005 dengan kapital salah, P099 yang tidak ada di master produk, P002 terkirim dua kali, qty kosong, dan harga berformat Rp 15.000. Pipeline juga membaca produk.csv dan kategori.csv dari chapter-chapter sebelumnya untuk validasi rujukan dan pengayaan data.
Kode Pipeline Utuh #
Simpan sebagai pipeline.py. Setiap tahap melaporkan dirinya lewat module logging — pesan progres inilah yang membuat pipeline bisa diawasi tanpa membuka debugger:
"""Pipeline penjualan: ekstrak -> validasi -> bersih -> transformasi -> agregasi -> muat."""
import argparse
import csv
import json
import logging
import sys
from collections import Counter
from pathlib import Path
logging.basicConfig(stream=sys.stdout, level=logging.INFO,
format="%(levelname)-7s %(message)s")
log = logging.getLogger("pipeline")
KOLOM_WAJIB = ("tanggal", "sku", "qty", "harga")
def ekstrak(path_input):
with open(path_input, newline="", encoding="utf-8") as berkas:
mentah = list(csv.DictReader(berkas))
log.info("Ekstrak : %d baris dari %s", len(mentah), path_input)
return mentah
def muat_sku_valid(path_produk):
with open(path_produk, newline="", encoding="utf-8") as berkas:
return {b["id"] for b in csv.DictReader(berkas)}
def validasi(mentah, sku_valid):
valid, ditolak = [], []
for nomor, baris in enumerate(mentah, start=2):
if not all(baris.get(kolom, "").strip() for kolom in KOLOM_WAJIB):
ditolak.append((nomor, "kolom kosong"))
elif baris["sku"].upper() not in sku_valid:
ditolak.append((nomor, f"sku tidak dikenal: {baris['sku']}"))
elif not baris["qty"].strip().isdigit():
ditolak.append((nomor, f"qty bukan angka: {baris['qty']!r}"))
else:
valid.append(baris)
for nomor, alasan in ditolak:
log.warning("Baris %d ditolak: %s", nomor, alasan)
log.info("Validasi : %d valid, %d ditolak", len(valid), len(ditolak))
return valid
def bersihkan(valid):
hasil = []
for baris in valid:
hasil.append({
"tanggal": baris["tanggal"],
"sku": baris["sku"].upper(),
"qty": int(baris["qty"]),
"harga": int(baris["harga"].replace("Rp", "").replace(".", "").strip()),
})
sebelum = len(hasil)
unik = {(b["tanggal"], b["sku"], b["qty"], b["harga"]): b for b in hasil}
log.info("Bersih : %d baris, %d duplikat dihapus", len(unik), sebelum - len(unik))
return list(unik.values())
def transformasi(bersih, path_produk, path_kategori):
with open(path_produk, newline="", encoding="utf-8") as berkas:
indeks_produk = {b["id"]: b for b in csv.DictReader(berkas)}
with open(path_kategori, newline="", encoding="utf-8") as berkas:
diskon = {b["kategori"]: int(b["diskon_persen"]) for b in csv.DictReader(berkas)}
kaya = []
for b in bersih:
produk = indeks_produk[b["sku"]]
kategori = produk["kategori"]
total = b["qty"] * b["harga"]
potongan = diskon.get(kategori, 0)
kaya.append({
**b,
"nama": produk["nama"],
"kategori": kategori,
"total": total,
"total_akhir": round(total * (1 - potongan / 100)),
})
log.info("Transform : %d baris diperkaya nama, kategori, dan diskon", len(kaya))
return kaya
def agregasi(kaya):
per_kategori = {}
for b in kaya:
stat = per_kategori.setdefault(b["kategori"], {"jumlah": 0, "qty": 0, "omzet": 0})
stat["jumlah"] += 1
stat["qty"] += b["qty"]
stat["omzet"] += b["total_akhir"]
omzet_sku = Counter()
for b in kaya:
omzet_sku[b["sku"]] += b["total_akhir"]
return {
"total_transaksi": len(kaya),
"omzet_keseluruhan": sum(b["total_akhir"] for b in kaya),
"per_kategori": dict(sorted(per_kategori.items(), key=lambda x: -x[1]["omzet"])),
"top_sku": [{"sku": sku, "omzet": total} for sku, total in omzet_sku.most_common(3)],
}
def muat(kaya, ringkasan, dir_output):
dir_output = Path(dir_output)
dir_output.mkdir(parents=True, exist_ok=True)
path_csv = dir_output / "penjualan_bersih.csv"
with open(path_csv, "w", newline="", encoding="utf-8") as berkas:
penulis = csv.DictWriter(berkas, fieldnames=list(kaya[0]))
penulis.writeheader()
penulis.writerows(kaya)
log.info("Muat : %d baris -> %s", len(kaya), path_csv)
path_json = dir_output / "ringkasan.json"
with open(path_json, "w", encoding="utf-8") as berkas:
json.dump(ringkasan, berkas, ensure_ascii=False, indent=2)
berkas.write("\n")
log.info("Muat : ringkasan -> %s", path_json)
def jalankan(path_input, path_produk, path_kategori, dir_output):
mentah = ekstrak(path_input)
valid = validasi(mentah, muat_sku_valid(path_produk))
bersih = bersihkan(valid)
kaya = transformasi(bersih, path_produk, path_kategori)
ringkasan = agregasi(kaya)
muat(kaya, ringkasan, dir_output)
return ringkasan
def main():
parser = argparse.ArgumentParser(description="Pipeline pembersihan data penjualan")
parser.add_argument("--input", default="penjualan_mentah.csv")
parser.add_argument("--produk", default="produk.csv")
parser.add_argument("--kategori", default="kategori.csv")
parser.add_argument("--output-dir", default="hasil")
argumen = parser.parse_args()
jalankan(argumen.input, argumen.produk, argumen.kategori, argumen.output_dir)
if __name__ == "__main__":
main()Mari kita bedah keputusan desain terpentingnya:
- Baris 63-67 (
bersihkan): normalisasiupper(), koersi"Rp 15.000"menjadi15000, lalu dedup dengan dictionary berkunci tuple seluruh kolom — pola dari chapter Membersihkan Data. - Baris 20-22 (
validasi): tiga aturan eksplisit — kolom lengkap, sku dikenal di master, qty numerik. Baris gagal ditolak dengan nomor barisnya, bukan didiamkan. - Baris 96-101 (
transformasi): join ke master produk (nama, kategori) dan master kategori (diskon), lalu menghitungtotaldantotal_akhir— paduan pola join dari chapter Menggabungkan Dataset dan field turunan dari chapter Transformasi Data. - Baris 134-135 (
logpadamuat):loggingdengan lazy formatting%smenunda pemformatan string sampai benar-benar ditulis — praktik standar dari chapter Module logging.
Menjalankan Pipeline dari CLI #
Argumen command-line ditangani argparse (chapter Membuat CLI dengan argparse), sehingga pipeline bisa dijadwalkan otomatis:
python3 pipeline.py --input penjualan_mentah.csv --output-dir hasilOutput yang Dihasilkan:
INFO Ekstrak : 12 baris dari penjualan_mentah.csv
WARNING Baris 4 ditolak: sku tidak dikenal: P099
WARNING Baris 9 ditolak: kolom kosong
INFO Validasi : 10 valid, 2 ditolak
INFO Bersih : 9 baris, 1 duplikat dihapus
INFO Transform : 9 baris diperkaya nama, kategori, dan diskon
INFO Muat : 9 baris -> hasil/penjualan_bersih.csv
INFO Muat : ringkasan -> hasil/ringkasan.jsonDua belas baris mentah menjadi sembilan baris bersih, dan setiap keputusan tercatat: baris 4 ditolak karena sku tak dikenal, baris 9 karena kolom kosong, satu duplikat dihapus. Hasil akhirnya dua berkas di direktori hasil/ — data bersih yang siap dipakai ulang:
tanggal,sku,qty,harga,nama,kategori,total,total_akhir
2025-06-13,P001,2,85000,Kopi Gayo,minuman,170000,153000
2025-06-13,P005,3,15000,Keripik Singkong,camilan,45000,38250
2025-06-14,P002,1,45000,Teh Melati,minuman,45000,40500
2025-06-15,P001,2,85000,Kopi Gayo,minuman,170000,153000
2025-06-15,P008,10,3500,Mie Instan Goreng,bahan pokok,35000,33250
2025-06-16,P005,5,15000,Keripik Singkong,camilan,75000,63750
2025-06-16,P004,1,78000,"Beras Pandan Wangi, 5kg",bahan pokok,78000,74100
2025-06-17,P005,2,15000,Keripik Singkong,camilan,30000,25500
2025-06-17,P010,1,250000,Kopi Luwak,minuman,250000,225000{
"total_transaksi": 9,
"omzet_keseluruhan": 806350,
"per_kategori": {
"minuman": {
"jumlah": 4,
"qty": 6,
"omzet": 571500
},
"camilan": {
"jumlah": 3,
"qty": 10,
"omzet": 127500
},
"bahan pokok": {
"jumlah": 2,
"qty": 11,
"omzet": 107350
}
},
"top_sku": [
{
"sku": "P001",
"omzet": 306000
},
{
"sku": "P010",
"omzet": 225000
},
{
"sku": "P005",
"omzet": 127500
}
]
}Idempotensi dan Titik Simpan #
Pipeline yang baik bersifat idempoten: menjalankannya dua kali menghasilkan keluaran yang sama, bukan data yang berlipat. Kita mendapatkannya secara gratis karena setiap tahap membaca ulang dari sumber dan menimpa keluaran lama dengan mode "w". Untuk menulis berkas keluaran secara aman dari gangguan (misalnya listrik padam di tengah penulisan), pola standarnya tulis dulu ke berkas sementara lalu ganti nama secara atomik:
import os
def tulis_atomic(path, isi):
path_tmp = path + ".tmp"
with open(path_tmp, "w", encoding="utf-8") as berkas:
berkas.write(isi)
os.replace(path_tmp, path)
tulis_atomic("status.txt", "selesai")
print(open("status.txt", encoding="utf-8").read())Output yang Dihasilkan:
selesaiFungsi os.replace() mengganti berkas lama dengan berkas baru dalam satu operasi sistem operasi — pembaca tidak pernah melihat berkas setengah jadi. Untuk pipeline multi-tahap yang mahal, konsep yang sama diperluas menjadi checkpoint: simpan keluaran antara (misalnya bersih.csv setelah tahap bersih) beserta jejak sumbernya, lalu lewati tahap yang masukannya belum berubah — biasanya dibandingkan lewat ukuran berkas atau hash.
Menguji Pipeline dengan pytest #
Pipeline yang dijadwalkan setiap malam butuh jaring pengaman: tes yang memastikan hasilnya tidak berubah diam-diam. Kita jalankan pipeline ke direktori uji, lalu bandingkan ringkasannya dengan nilai emas (golden values) yang sudah diverifikasi:
import json
from pipeline import jalankan
def test_ringkasan_stabil():
jalankan("penjualan_mentah.csv", "produk.csv", "kategori.csv", "hasil_test")
with open("hasil_test/ringkasan.json", encoding="utf-8") as berkas:
ringkasan = json.load(berkas)
assert ringkasan["total_transaksi"] == 9
assert ringkasan["omzet_keseluruhan"] == 806350
assert ringkasan["top_sku"][0] == {"sku": "P001", "omzet": 306000}
assert ringkasan["per_kategori"]["minuman"]["omzet"] == 571500Simpan sebagai test_pipeline.py lalu jalankan pytest (chapter Menulis Test dengan pytest):
python3 -m pytest test_pipeline.py -qOutput yang Dihasilkan:
. [100%]
1 passed in 0.12sFungsi jalankan() dipanggil langsung tanpa subprocess — pipeline yang dirancang sebagai kumpulan fungsi murni per tahap otomatis mudah diuji. Jika sumber data berubah dan angka emas perlu diperbarui, tes gagal dengan pesan yang jelas; untuk pipeline besar, pola golden file membandingkan seluruh berkas keluaran byte-per-byte terhadap salinan yang telah diverifikasi manual.
Dari membaca satu file CSV sampai pipeline yang teruji — perjalanan section ini menutup satu lingkaran penuh. Regulasi berikutnya setelah ini adalah eksplorasi: saat fokus bergeser dari “membuat data layak pakai” ke “menemukan cerita di dalamnya”, section Data Science menanti dengan pandas, NumPy, dan visualisasi.