Bab 43 — Aplikasi Data Cepat dan Dasbor: Dari Skrip ke Layar Orang Lain
Seri I5 — Aplikasi Pertama: Web, Peta, dan Dasbor
Semua skrip bab ini ada di folder kode/bab43/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).
Bab 42 menutup dengan dasbor statis: bagus, tetapi beku. Kali ini kita membuat dasbor yang hidup: pengguna memilih sektor, angkanya berubah, peta bergeser. Untungnya Python punya pustaka yang membuat ini mungkin tanpa harus belajar JavaScript. Bayangkan sebuah toko: dulu Anda menempel daftar harga di dinding (dasbor statis); sekarang Anda punya kasir yang menjawab pertanyaan pembeli satu per satu.

Kita memakai data unit usaha tiruan yang sama dengan bab sebelumnya (15 unit, lima sektor, semua angka fiktif). Semua pustaka di bawah sudah terpasang di komputer penulis dan diuji dengan menjalankannya; versi pustaka terus berubah, jadi bila perilakunya berbeda di komputer Anda, periksa dokumentasi resmi terbaru.
Satu keterbatasan yang jujur: bab ini tidak bisa memperlihatkan tampilan di peramban. Semua yang dijalankan di sini diuji lewat kode (tanpa membuka peramban), sehingga yang terbukti adalah logika dan isinya, bukan penampilannya.
Streamlit: dasbor dari satu skrip
Streamlit mengubah skrip Python biasa menjadi halaman web. Idenya sederhana: skrip dibaca dari atas ke bawah, dan setiap kali pengguna mengubah sesuatu (memilih, menggeser), seluruh skrip dijalankan ulang. Tidak ada HTML, tidak ada JavaScript.
app_streamlit.py
# Bab 43 - Dasbor Streamlit per sektor (modul bantu; dijalankan dengan: streamlit run app_streamlit.py)
import pandas as pd
import streamlit as st
from data_unit import baca_unit
from peta import buat_peta
SEKTOR = ["semua", "hutan", "kebun", "sawah", "kolam", "kandang"]
@st.cache_data # hasil disimpan; tidak membaca CSV ulang setiap interaksi
def muat_unit():
return pd.DataFrame(baca_unit())
st.set_page_config(page_title="Dasbor unit usaha", layout="wide")
st.title("Dasbor unit usaha (data fiktif)")
sektor = st.sidebar.selectbox("Sektor", SEKTOR)
unit = muat_unit()
tampil = unit if sektor == "semua" else unit[unit["sektor"] == sektor]
kiri, tengah, kanan = st.columns(3)
kiri.metric("Jumlah unit", len(tampil))
tengah.metric("Rata-rata nilai", f"{tampil['nilai'].mean():.1f}")
kanan.metric("Nilai tertinggi", f"{tampil['nilai'].max():.1f}")
if sektor == "semua":
st.info("Nilai tiap sektor berbeda satuan (cm atau kg): pilih satu sektor untuk membandingkan.")
else:
st.bar_chart(tampil.set_index("unit")["nilai"])
st.dataframe(tampil[["id", "sektor", "unit", "jenis", "nilai", "satuan"]], hide_index=True)
st.map(tampil, latitude="lat", longitude="lon")
with st.expander("Peta interaktif (folium)"):
st.iframe(buat_peta(tampil.to_dict("records")).get_root().render(), height=420)Bacalah seperti resep:
st.sidebar.selectbox(...)membuat kotak pilihan di bilah samping dan mengembalikan pilihan sekarang.st.columns(3)dan.metric(...)membuat tiga kartu angka.st.bar_chart,st.dataframe, danst.mapmembuat grafik, tabel, dan peta titik.st.mapdiberi nama kolom lintang dan bujur (latdanlon).@st.cache_datamenyimpan hasilmuat_unit()supaya CSV tidak dibaca ulang setiap interaksi.- Bagian terakhir menanam peta folium dalam sebuah lipatan (dibahas di bagian peta).
Aplikasi ini dijalankan dengan streamlit run app_streamlit.py, yang membuka alamat di komputer Anda. Perintah itu tidak dijalankan saat buku dibuat, sebab ia menyalakan server yang menunggu peramban. Sebagai gantinya, Streamlit menyediakan AppTest (dokumentasi resmi: pengujian tanpa peramban): ia menjalankan skrip di memori dan memberi akses ke elemennya.
01_streamlit_apptest.py
# Bab 43 - Streamlit: menjalankan dasbor TANPA peramban memakai AppTest
import logging
from streamlit.testing.v1 import AppTest
# "bare mode": AppTest tidak punya peramban, jadi Streamlit mengeluh pelan-pelan; diamkan peringatannya
logging.getLogger("streamlit.runtime.scriptrunner_utils.script_run_context").setLevel(logging.ERROR)
at = AppTest.from_file("app_streamlit.py", default_timeout=30).run()
print("ada galat :", bool(at.exception))
print("judul :", at.title[0].value)
print("pilihan sektor :", at.selectbox[0].options)
print("sektor terpilih:", at.selectbox[0].value)
for m in at.metric:
print(f"metrik : {m.label} = {m.value}")
print("baris tabel :", len(at.dataframe[0].value))
print("kotak info :", at.info[0].value[:40] + "...")ada galat : False
judul : Dasbor unit usaha (data fiktif)
pilihan sektor : ['semua', 'hutan', 'kebun', 'sawah', 'kolam', 'kandang']
sektor terpilih: semua
metrik : Jumlah unit = 15
metrik : Rata-rata nilai = 375.0
metrik : Nilai tertinggi = 1250.0
baris tabel : 15
kotak info : Nilai tiap sektor berbeda satuan (cm ata...
Pada awal, sektor terpilih adalah semua, dan angka-angkanya benar: 15 unit, rata-rata nilai 375,0 (angka ini mencampur cm dan kg, sehingga aplikasi menampilkan kotak info yang memperingatkannya), dan nilai tertinggi 1250,0 (petak sawah 1). Kini kita meniru pengguna:
02_streamlit_interaksi.py
# Bab 43 - Streamlit: meniru pengguna memilih sektor, lalu memeriksa hasilnya
import logging
from streamlit.testing.v1 import AppTest
logging.getLogger("streamlit.runtime.scriptrunner_utils.script_run_context").setLevel(logging.ERROR)
at = AppTest.from_file("app_streamlit.py", default_timeout=30).run()
for sektor in ["hutan", "kolam", "kandang"]:
at.selectbox[0].select(sektor).run() # seperti pengguna mengklik pilihan di bilah samping
nilai = {m.label: m.value for m in at.metric}
grafik = len(at.get("vega_lite_chart")) # st.bar_chart = elemen grafik Vega-Lite
peta = len(at.get("deck_gl_json_chart")) # st.map = elemen peta deck.gl
print(f"{sektor:8} unit={nilai['Jumlah unit']} rata-rata={nilai['Rata-rata nilai']} "
f"tertinggi={nilai['Nilai tertinggi']} grafik={grafik} peta={peta} baris={len(at.dataframe[0].value)}")
# Memilih sesuatu yang tidak ada dalam daftar gagal ketika aplikasi dijalankan ulang
try:
at.selectbox[0].select("empang").run()
except ValueError as e:
print("pilihan tidak sah ditolak:", type(e).__name__)hutan unit=3 rata-rata=35.2 tertinggi=45.2 grafik=1 peta=1 baris=3
kolam unit=3 rata-rata=369.3 tertinggi=432.4 grafik=1 peta=1 baris=3
kandang unit=3 rata-rata=383.8 tertinggi=412.5 grafik=1 peta=1 baris=3
pilihan tidak sah ditolak: ValueError
at.selectbox[0].select("kolam").run() adalah “pengguna memilih kolam, skrip berjalan ulang”. Hasilnya cocok dengan hitungan tangan: kolam punya 3 unit, rata-rata (432,4 + 380,0 + 295,5) / 3 = 369,3, tertinggi 432,4. Grafik batang dan peta muncul sebagai satu elemen masing-masing. Memilih sesuatu yang tidak ada di daftar (empang) dijawab ValueError saat aplikasi dijalankan ulang.
AppTest membuktikan bahwa logika aplikasi benar dan tidak ada pengecualian. Ia tidak membuktikan bahwa grafik tampak cantik. Grafik dan peta hanya terlihat sebagai elemen bertipe tertentu (vega_lite_chart, deck_gl_json_chart); bentuk di layar tetap harus dilihat manusia.
Gradio: demo fungsi dan model
Gradio punya tujuan sedikit berbeda dari Streamlit: membungkus sebuah fungsi dengan kotak masukan dan keluaran otomatis. Sangat disukai untuk memamerkan model: pengguna mengetik sesuatu, model menjawab. Di sini “model” kita sengaja sederhana: rumus taksiran bobot ikan dari panjangnya, berupa contoh fiktif, bukan hasil penelitian.
app_gradio.py
# Bab 43 - Demo Gradio: cari unit dan "model" taksir bobot ikan (modul bantu)
import gradio as gr
from data_unit import baca_unit
SEKTOR = ["semua", "hutan", "kebun", "sawah", "kolam", "kandang"]
def cari_unit(sektor, nilai_minimal):
"""Mengembalikan tabel unit dan kalimat ringkasan."""
terpilih = [
[u["id"], u["sektor"], u["unit"], u["nilai"], u["satuan"]]
for u in baca_unit()
if sektor in ("semua", u["sektor"]) and u["nilai"] >= nilai_minimal
]
return terpilih, f"{len(terpilih)} unit cocok"
def taksir_bobot(panjang_cm):
"""Pengganti 'model': bobot = 0.01 x panjang^3 (rumus contoh FIKTIF, bukan hasil penelitian)."""
return round(0.01 * panjang_cm ** 3, 1)
antarmuka_cari = gr.Interface(
fn=cari_unit,
inputs=[gr.Dropdown(SEKTOR, value="semua", label="Sektor"),
gr.Slider(0, 1000, value=0, step=10, label="Nilai minimal")],
outputs=[gr.Dataframe(headers=["id", "sektor", "unit", "nilai", "satuan"], label="Unit"),
gr.Textbox(label="Ringkasan")],
api_name="cari",
flagging_mode="never",
)
antarmuka_taksir = gr.Interface(
fn=taksir_bobot,
inputs=gr.Number(value=25, label="Panjang ikan (cm)"),
outputs=gr.Number(label="Taksiran bobot (g)"),
api_name="taksir",
flagging_mode="never",
)
demo = gr.TabbedInterface([antarmuka_cari, antarmuka_taksir], ["Cari unit", "Taksir bobot"],
title="Demo unit usaha (data fiktif)")
if __name__ == "__main__":
demo.launch() # alamat lokal saja; share=True TIDAK dipakaiDua fungsi, dua gr.Interface, digabung dalam tab. api_name memberi nama pada tiap titik akses supaya bisa dipanggil dari program lain. Ada dua cara menguji:
03_gradio_fungsi.py
# Bab 43 - Gradio: fungsinya diuji langsung, lalu aplikasinya dinyalakan di komputer sendiri
import contextlib
import io
import os
os.environ["GRADIO_ANALYTICS_ENABLED"] = "False" # jangan kirim statistik pemakaian
os.environ["HF_HUB_OFFLINE"] = "1" # jangan menghubungi huggingface.co
from gradio_client import Client
from app_gradio import cari_unit, demo, taksir_bobot
# 1. Fungsi biasa: tidak perlu server untuk mengujinya
tabel, ringkasan = cari_unit("kolam", 300)
print("langsung :", ringkasan, "->", [baris[2] for baris in tabel])
print("langsung :", taksir_bobot(25), "g untuk ikan 25 cm")
# 2. Nyalakan server lokal, lalu panggil lewat klien resmi (seperti pengguna lewat jaringan)
demo.launch(server_name="127.0.0.1", prevent_thread_lock=True, share=False, quiet=True)
try:
klien = Client(demo.local_url, verbose=False)
data, teks = klien.predict("kandang", 0, api_name="/cari")
print("klien cari :", teks, "->", [b[2] for b in data["data"]])
print("klien taksir:", klien.predict(30, api_name="/taksir"), "g untuk ikan 30 cm")
finally:
with contextlib.redirect_stdout(io.StringIO()): # menyembunyikan pesan nomor port
demo.close()
print("server ditutup")langsung : 2 unit cocok -> ['Kolam A', 'Kolam B']
langsung : 156.2 g untuk ikan 25 cm
klien cari : 3 unit cocok -> ['Kandang 1', 'Kandang 2', 'Kandang 3']
klien taksir: 270.0 g untuk ikan 30 cm
server ditutup
- Panggil fungsinya langsung: 2 unit kolam bernilai ≥ 300 (Kolam A dan B), dan 0,01 × 25³ = 156,25, yang dibulatkan Python menjadi 156,2 g.
- Nyalakan server lokal lalu panggil lewat
gradio_client, klien resmi: seperti pengguna jarak jauh. 3 unit kandang cocok, dan ikan 30 cm: 0,01 × 30³ = 270,0 g.
Tiga catatan keamanan dan privasi:
- Kita tidak memakai
share=True. Menurut dokumentasi Gradio, opsi itu membuat tautan publik sementara (berlaku sekitar satu pekan) yang meneruskan lalu lintas ke komputer Anda; siapa pun yang punya tautan bisa memakai aplikasi, sementara komputer Anda yang bekerja. Jangan lakukan itu pada data yang bukan untuk umum. - Saat menguji, kami memantau sambungan jaringan dan menemukan bahwa Gradio menghubungi
huggingface.cobila tidak dicegah. Kami tidak menyelidiki tujuannya. DenganHF_HUB_OFFLINE=1(danGRADIO_ANALYTICS_ENABLED=Falseuntuk statistik pemakaian), tidak ada lagi sambungan ke luar komputer yang terdeteksi. Bila Anda memakai Gradio untuk data sensitif, periksa pengaturan ini. demo.launch()tanpa argumen hanya melayani komputer Anda sendiri.
Dash: dasbor yang sepenuhnya Anda atur
Dash (dari Plotly) lebih “dari bawah”: Anda menyusun tata letak (kerangka halaman dari komponen) dan callback (fungsi yang dipanggil bila sebuah komponen berubah). Lebih banyak kode daripada Streamlit, tetapi kendalinya lebih halus.
app_dash.py
# Bab 43 - Dasbor Dash (modul bantu; dijalankan dengan: python app_dash.py)
import pandas as pd
import plotly.express as px
from dash import Dash, Input, Output, dcc, html
from data_unit import baca_unit
SEKTOR = ["hutan", "kebun", "sawah", "kolam", "kandang"]
unit = pd.DataFrame(baca_unit())
app = Dash(__name__)
app.layout = html.Div([
html.H1("Dasbor Dash (data fiktif)"),
dcc.Dropdown(id="sektor", options=SEKTOR, value="kolam", clearable=False),
html.Div(id="ringkasan"),
dcc.Graph(id="grafik"),
])
@app.callback(Output("grafik", "figure"), Output("ringkasan", "children"), Input("sektor", "value"))
def perbarui(sektor):
bagian = unit[unit["sektor"] == sektor]
gambar = px.bar(bagian, x="unit", y="nilai", title=f"Sektor {sektor}")
return gambar, f"{len(bagian)} unit, rata-rata {bagian['nilai'].mean():.1f} {bagian['satuan'].iloc[0]}"
if __name__ == "__main__":
app.run(debug=False) # http://127.0.0.1:8050Dekorator @app.callback berkata: “bila nilai sektor berubah, panggil fungsi ini dan taruh hasilnya di grafik.figure dan ringkasan.children.” Dash berdiri di atas Flask, sehingga kita bisa memakai klien uji Flask tanpa peramban:
04_dash_tata_letak.py
# Bab 43 - Dash: menguji dasbor lewat klien uji Flask, tanpa peramban
from app_dash import app, perbarui
klien = app.server.test_client() # Dash berdiri di atas Flask
halaman = klien.get("/")
print("halaman utama:", halaman.status_code, "| memuat penyaji Dash:", b"_dash-renderer" in halaman.data)
tata_letak = klien.get("/_dash-layout").get_json()
anak = tata_letak["props"]["children"]
print("tata letak :", [(a["type"], a["props"].get("id")) for a in anak])
# Meniru permintaan yang dikirim peramban saat pengguna mengganti sektor
isi = {
"output": "..grafik.figure...ringkasan.children..",
"outputs": [{"id": "grafik", "property": "figure"}, {"id": "ringkasan", "property": "children"}],
"inputs": [{"id": "sektor", "property": "value", "value": "sawah"}],
"changedPropIds": ["sektor.value"],
}
jawab = klien.post("/_dash-update-component", json=isi)
print("callback :", jawab.status_code, jawab.get_json()["response"]["ringkasan"]["children"])
# Fungsinya juga bisa dipanggil langsung seperti fungsi biasa
print("langsung :", perbarui("kandang")[1])halaman utama: 200 | memuat penyaji Dash: True
tata letak : [('H1', None), ('Dropdown', 'sektor'), ('Div', 'ringkasan'), ('Graph', 'grafik')]
callback : 200 2 unit, rata-rata 1115.2 kg
langsung : 3 unit, rata-rata 383.8 kg
Halaman utama menjawab 200 dan memuat penyaji Dash. Tata letak berisi judul H1, daftar pilihan sektor, ringkasan, dan grafik. Kita lalu mengirim permintaan yang sama seperti yang dikirim peramban saat pengguna memilih sawah: jawabannya “2 unit, rata-rata 1115.2 kg”, cocok dengan (1250,0 + 980,5) / 2 = 1115,25 yang dibulatkan satu desimal. Fungsi callback juga bisa dipanggil langsung.
Pegangan memilih: Streamlit untuk dasbor cepat dan prototipe; Gradio untuk memamerkan satu fungsi atau model; Dash bila tata letak dan interaksi perlu diatur rinci. Untuk dasbor internal sederhana, mulailah dari Streamlit.
Peta interaktif di dasbor
Di Bab 38 sampai 40 Anda membuat peta. Untuk peta di dasbor, folium membungkus pustaka peta JavaScript Leaflet; pydeck membungkus deck.gl untuk lapisan titik atau data besar. Keduanya menghasilkan HTML.
peta.py
# Bab 43 - Modul bantu: peta interaktif lokasi unit usaha dengan folium (koordinat FIKTIF)
import folium
WARNA = {"hutan": "darkgreen", "kebun": "orange", "sawah": "green", "kolam": "blue", "kandang": "red"}
def buat_peta(unit):
"""Satu penanda per unit, diberi warna menurut sektor dan keterangan saat diklik."""
tengah = [sum(u["lat"] for u in unit) / len(unit), sum(u["lon"] for u in unit) / len(unit)]
peta = folium.Map(location=tengah, zoom_start=13, tiles="OpenStreetMap")
for u in unit:
folium.Marker(
[u["lat"], u["lon"]],
tooltip=f'{u["unit"]} ({u["sektor"]})',
popup=f'{u["unit"]}: {u["nilai"]} {u["satuan"]}',
icon=folium.Icon(color=WARNA[u["sektor"]]),
).add_to(peta)
return peta05_peta_interaktif.py
# Bab 43 - Peta interaktif: folium (Leaflet) dan pydeck (deck.gl), disimpan sebagai berkas HTML
from pathlib import Path
import pydeck as pdk
from data_unit import baca_unit
from peta import buat_peta
unit = baca_unit()
keluar = Path(__file__).parent / "_tmp"
keluar.mkdir(exist_ok=True)
# 1. folium: satu penanda per unit
peta = buat_peta(unit)
berkas = keluar / "peta_folium.html"
peta.save(berkas)
html = berkas.read_text(encoding="utf-8")
print("folium : penanda =", html.count("L.marker("), "| rujukan Leaflet:", "leaflet" in html.lower())
print(" berkas HTML dibuat:", berkas.stat().st_size > 5000)
# 2. pydeck: lapisan titik dengan ukuran sebanding nilai (untuk perbandingan sektor yang sama satuannya)
kolam = [u for u in unit if u["sektor"] == "kolam"]
lapisan = pdk.Layer("ScatterplotLayer", data=kolam, get_position=["lon", "lat"],
get_radius="nilai", radius_scale=0.5, get_fill_color=[30, 100, 200, 160])
tampilan = pdk.ViewState(latitude=kolam[0]["lat"], longitude=kolam[0]["lon"], zoom=13)
deck = pdk.Deck(layers=[lapisan], initial_view_state=tampilan)
berkas2 = keluar / "peta_pydeck.html"
deck.to_html(str(berkas2), open_browser=False)
print("pydeck : titik kolam =", len(kolam), "| berkas HTML dibuat:", berkas2.stat().st_size > 1000)
# Isi kedua berkas memuat pustaka peta dari internet saat DIBUKA di peramban (bukan saat dibuat di sini)
print("pustaka dimuat dari CDN:", "https://" in html and "https://" in berkas2.read_text(encoding="utf-8"))folium : penanda = 15 | rujukan Leaflet: True
berkas HTML dibuat: True
pydeck : titik kolam = 3 | berkas HTML dibuat: True
pustaka dimuat dari CDN: True
Ada 15 penanda (satu per unit) pada peta folium, dan 3 titik kolam pada peta pydeck. Perhatikan baris terakhir: kedua berkas memuat pustaka petanya dari internet saat dibuka di peramban, bukan saat dibuat. Jadi peta ini butuh sambungan internet untuk dilihat; kami tidak membukanya di sini. Streamlit menanam peta folium memakai st.iframe(...). (Fungsi lama st.components.v1.html ditandai usang oleh Streamlit 1.65 dengan st.iframe sebagai penggantinya.) Dokumentasi Streamlit juga memperingatkan: jangan menanam HTML dari sumber tak tepercaya lewat cara ini; peta kita dibuat sendiri dari data kita.
Ukuran titik pada peta pydeck memakai kolom nilai. Itu adil hanya untuk sektor dengan satuan sama. Kita memilih sektor kolam (semua dalam kg). Menggambar hutan (cm) berdampingan dengan kolam (kg) pada satu skala akan menyesatkan.
HTMX: web ringan tanpa kerangka besar
Kadang yang Anda butuhkan hanya halaman yang sebagian isinya berganti tanpa memuat ulang seluruh halaman. HTMX adalah pustaka JavaScript kecil yang memberi HTML kemampuan itu lewat atribut: hx-get (minta ke alamat ini), hx-trigger (kapan), hx-target (isi bagian mana), hx-swap (cara mengganti). Servernya, di sini Flask, cukup mengirim potongan HTML, bukan JSON.
app_htmx.py
# Bab 43 - Web ringan dengan HTMX + Flask (modul bantu; dijalankan dengan: flask --app app_htmx run)
from flask import Flask, abort, render_template_string, request
from data_unit import baca_unit
app = Flask(__name__)
SEKTOR = ["hutan", "kebun", "sawah", "kolam", "kandang"]
HALAMAN = """<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8"><title>Unit usaha (HTMX)</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"></script>
</head>
<body>
<h1>Unit usaha (data fiktif)</h1>
<select name="s" hx-get="/sektor" hx-trigger="change" hx-target="#hasil" hx-swap="innerHTML">
<option value="">- pilih sektor -</option>
{% for s in sektor %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select>
<div id="hasil">Pilih sektor di atas.</div>
</body>
</html>"""
TABEL = """<table>
{% for u in unit %}<tr><td>{{ u.unit }}</td><td>{{ u.jenis }}</td><td>{{ u.nilai }} {{ u.satuan }}</td></tr>
{% endfor %}
</table>"""
@app.get("/")
def indeks():
return render_template_string(HALAMAN, sektor=SEKTOR)
@app.get("/sektor")
def per_sektor():
s = request.args.get("s", "")
if s not in SEKTOR:
abort(400, "sektor tidak dikenal")
unit = [u for u in baca_unit() if u["sektor"] == s]
return render_template_string(TABEL, unit=unit) # hanya potongan HTML, bukan satu halaman penuhPemilihan sektor mengirim GET /sektor?s=kolam, dan server menjawab tabel kecil yang ditaruh di #hasil. Skrip HTMX dimuat dari CDN jsDelivr; versi 2.0.11 adalah versi terbaru di registri npm saat ini ditulis (dibaca 2026-10-10). Pada penerapan sungguhan, Anda bisa menambahkan atribut integrity (Subresource Integrity) seperti pada contoh dokumentasi HTMX; kami tidak menyalin hash itu dari ingatan.
06_htmx_flask.py
# Bab 43 - HTMX + Flask: sisi server diuji dengan klien uji (JavaScript htmx TIDAK dijalankan di sini)
from app_htmx import app
klien = app.test_client()
halaman = klien.get("/").get_data(as_text=True)
print("halaman utama:", len(halaman.splitlines()), "baris")
for atribut in ("hx-get=", "hx-trigger=", "hx-target=", "htmx.org@2.0.11"):
print(f" memuat {atribut:18}", atribut in halaman)
# Yang dikirim htmx saat pengguna memilih: GET /sektor?s=kolam dengan header HX-Request
jawab = klien.get("/sektor?s=kolam", headers={"HX-Request": "true"})
potongan = jawab.get_data(as_text=True)
print("\nGET /sektor?s=kolam ->", jawab.status_code, "| potongan HTML, bukan halaman penuh:", "<html" not in potongan)
print(potongan)
print("sektor aneh ->", klien.get("/sektor?s=empang").status_code)
print("tanpa sektor ->", klien.get("/sektor").status_code)halaman utama: 15 baris
memuat hx-get= True
memuat hx-trigger= True
memuat hx-target= True
memuat htmx.org@2.0.11 True
GET /sektor?s=kolam -> 200 | potongan HTML, bukan halaman penuh: True
<table>
<tr><td>Kolam A</td><td>nila</td><td>432.4 kg</td></tr>
<tr><td>Kolam B</td><td>lele</td><td>380.0 kg</td></tr>
<tr><td>Kolam C</td><td>nila</td><td>295.5 kg</td></tr>
</table>
sektor aneh -> 400
tanpa sektor -> 400
Yang terbukti: halaman memuat atribut HTMX dan skrip versi 2.0.11; endpoint /sektor menjawab potongan HTML (bukan halaman penuh) berisi tiga kolam; sektor aneh atau kosong ditolak dengan kode 400. Yang tidak teruji: JavaScript HTMX di peramban, yaitu bahwa memilih sektor benar-benar mengganti isi #hasil. Itu butuh peramban dan kami tidak membukanya.
FastAPI: pintu untuk program lain
Dasbor adalah pintu untuk manusia. API adalah pintu untuk program lain: aplikasi ponsel, skrip lain, atau dasbor di tempat lain yang meminta data dalam bentuk JSON. Di Bab 36 Anda mengenal FastAPI; kini kita memakainya untuk data semua unit usaha, dengan model Pydantic dari Bab 34.
api_unit.py
# Bab 43 - API FastAPI untuk data semua unit usaha (modul bantu)
from typing import Literal
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from data_unit import baca_unit, ringkas_per_sektor
Sektor = Literal["hutan", "kebun", "sawah", "kolam", "kandang"]
class Unit(BaseModel):
id: str
sektor: Sektor
unit: str
jenis: str
lat: float
lon: float
nilai: float
satuan: str
class Ringkasan(BaseModel):
sektor: Sektor
jumlah: int
total: float
rata: float
satuan: str
app = FastAPI(title="API unit usaha (data fiktif)")
@app.get("/unit", response_model=list[Unit])
def daftar_unit(sektor: Sektor | None = None):
return [u for u in baca_unit() if sektor is None or u["sektor"] == sektor]
@app.get("/unit/{id_unit}", response_model=Unit)
def satu_unit(id_unit: str):
for u in baca_unit():
if u["id"] == id_unit:
return u
raise HTTPException(status_code=404, detail=f"unit {id_unit} tidak ada")
@app.get("/ringkasan", response_model=list[Ringkasan])
def ringkasan():
return [{"sektor": s, **r} for s, r in ringkas_per_sektor(baca_unit()).items()]07_fastapi_api.py
# Bab 43 - FastAPI: API data semua unit usaha, diuji dengan TestClient
from fastapi.testclient import TestClient
from api_unit import app
klien = TestClient(app)
semua = klien.get("/unit").json()
print("GET /unit :", len(semua), "unit; pertama =", semua[0]["id"], semua[0]["unit"])
kolam = klien.get("/unit", params={"sektor": "kolam"}).json()
print("GET /unit?sektor=kolam :", [u["unit"] for u in kolam])
satu = klien.get("/unit/D2")
print("GET /unit/D2 :", satu.status_code, satu.json()["nilai"], satu.json()["satuan"])
hilang = klien.get("/unit/X9")
print("GET /unit/X9 :", hilang.status_code, hilang.json())
salah = klien.get("/unit", params={"sektor": "empang"})
print("GET /unit?sektor=empang:", salah.status_code, salah.json()["detail"][0]["type"])
print("GET /ringkasan :", [(r["sektor"], r["jumlah"], round(r["rata"], 1)) for r in klien.get("/ringkasan").json()])
print("dokumentasi otomatis :", klien.get("/docs").status_code, "| skema:", klien.get("/openapi.json").status_code)GET /unit : 15 unit; pertama = H1 Plot A
GET /unit?sektor=kolam : ['Kolam A', 'Kolam B', 'Kolam C']
GET /unit/D2 : 200 412.5 kg
GET /unit/X9 : 404 {'detail': 'unit X9 tidak ada'}
GET /unit?sektor=empang: 422 literal_error
GET /ringkasan : [('hutan', 3, 35.2), ('kebun', 4, 257.5), ('sawah', 2, 1115.2), ('kolam', 3, 369.3), ('kandang', 3, 383.8)]
dokumentasi otomatis : 200 | skema: 200
TestClient memanggil API tanpa menyalakan server. Hasilnya menunjukkan: 15 unit; tiga kolam bila disaring; D2 ditemukan (412,5 kg); X9 menghasilkan 404 dengan pesan jelas; sektor empang menghasilkan 422, galat validasi otomatis karena bukan salah satu isi Literal; dan FastAPI membuatkan dokumentasi interaktif (/docs) dan skema (/openapi.json) tanpa Anda menulisnya.
Menyajikan aplikasi kepada pengguna
Sejauh ini semuanya berjalan di 127.0.0.1, alamat yang hanya bisa dijangkau komputer ini. Agar orang lain memakainya, aplikasi harus berjalan di mesin yang bisa dijangkau mereka. Mari lihat server sungguhan pertama, uvicorn, yang menjalankan API kita lewat HTTP asli:
08_penyajian_lokal.py
# Bab 43 - Menyajikan aplikasi: server sungguhan (uvicorn) di komputer sendiri + berkas penyajian
import socket
import threading
import time
from pathlib import Path
import httpx
import uvicorn
from api_unit import app
# Cari port yang sedang kosong, lalu nyalakan server di thread terpisah
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
while not server.started:
time.sleep(0.05)
try:
jawab = httpx.get(f"http://127.0.0.1:{port}/ringkasan", timeout=5)
print("lewat HTTP sungguhan:", jawab.status_code, "|", len(jawab.json()), "sektor")
finally:
server.should_exit = True # minta server berhenti dengan rapi
thread.join(timeout=10)
print("server sudah berhenti:", not thread.is_alive())
# Berkas yang biasanya menyertai aplikasi saat dipindah ke server lain
keluar = Path(__file__).parent / "_tmp" / "penyajian"
keluar.mkdir(parents=True, exist_ok=True)
(keluar / "requirements.txt").write_text("fastapi\nuvicorn\nstreamlit\npandas\nfolium\n", encoding="utf-8")
perintah = {
"API (FastAPI)": "uvicorn api_unit:app --host 127.0.0.1 --port 8000",
"Dasbor (Streamlit)": "streamlit run app_streamlit.py",
"Demo (Gradio)": "python app_gradio.py",
"Dasbor (Dash)": "python app_dash.py",
"Web ringan (Flask)": "flask --app app_htmx run",
}
print("\nPerintah menyalakan tiap aplikasi (dijalankan SENDIRI di terminal, tidak di sini):")
for nama, p in perintah.items():
print(f" {nama:20} {p}")lewat HTTP sungguhan: 200 | 5 sektor
server sudah berhenti: True
Perintah menyalakan tiap aplikasi (dijalankan SENDIRI di terminal, tidak di sini):
API (FastAPI) uvicorn api_unit:app --host 127.0.0.1 --port 8000
Dasbor (Streamlit) streamlit run app_streamlit.py
Demo (Gradio) python app_gradio.py
Dasbor (Dash) python app_dash.py
Web ringan (Flask) flask --app app_htmx run
Server dinyalakan di port yang sedang kosong, dijawab 200 dengan 5 sektor, lalu dihentikan dengan rapi. Daftar di bawahnya adalah perintah penyalaan tiap aplikasi di bab ini, yang Anda jalankan sendiri di terminal; tidak ada yang dijalankan di sini.
Bagaimana memindahkannya agar orang lain bisa memakai? Berikut pilihan yang disebut dokumentasi resmi masing-masing. Kami tidak mendaftar, mengunggah, atau mencoba satu pun; syarat, harga, dan batasan bisa berubah, jadi baca halaman resminya sebelum memutuskan.
| Aplikasi | Pilihan menurut dokumentasi |
|---|---|
| Streamlit | Streamlit Community Cloud (dijelaskan sebagai hosting gratis untuk aplikasi nonkomersial, pribadi, dan pendidikan), Snowflake, atau platform lain lewat tutorial |
| Gradio | share=True (tautan sementara dari komputer Anda), Hugging Face Spaces (tautan permanen), atau dipasang pada aplikasi FastAPI milik sendiri dengan gr.mount_gradio_app() |
| Dash | Plotly Cloud (terkelola) atau Dash Enterprise (untuk organisasi) |
| FastAPI, Flask | server milik sendiri atau sewaan, menjalankan uvicorn seperti di atas |
Catatan dari dokumentasi Hugging Face yang perlu dibaca teliti: saat ditulis, ruang (Space) bertipe Gradio atau Docker butuh paket berbayar untuk dibuat, sedangkan ruang statis gratis, dan ruang di perangkat keras gratis akan “tidur” bila lama tak dipakai. Dokumentasinya juga menyarankan menaruh rahasia (kunci, token) sebagai secret pengaturan, bukan menuliskannya di kode.
Sebelum menyajikan apa pun ke publik, tanya diri Anda: (1) apakah datanya boleh dilihat semua orang? Dasbor tanpa kata sandi terbuka bagi siapa pun yang punya alamatnya; (2) apakah ada rahasia di dalam kode? Kunci dan kata sandi tidak boleh ada di kode atau repositori; (3) siapa yang merawatnya bila rusak? Bab-bab Seri A1 membahas keamanan dan penerapan (deployment) secara serius.
Proyek Bersambung I5 (penutup): satu data, banyak pintu
Proyek Seri I5 kini lengkap: data unit usaha dibaca oleh satu modul, lalu dibuka lewat banyak pintu.

- API FastAPI (bab ini) untuk program lain.
- Dasbor Streamlit per sektor (bab ini) untuk manusia.
- CLI
unit(Bab 42) untuk terminal dan otomasi. - Laporan mingguan dengan scraping yang sopan (Bab 41).
- Demo di peramban tanpa server (Bab 37), dan peta (Bab 38 sampai 40).
Karena semua pintu membaca data yang sama, angkanya harus sama. Skrip penutup memeriksa itu: rata-rata per sektor dihitung langsung dari modul, lewat API, dan lewat dasbor, lalu dibandingkan.
09_proyek_penutup.py
# Bab 43 - Proyek bersambung I5 (penutup): satu data, tiga jalur akses, hasilnya harus sama
import logging
from fastapi.testclient import TestClient
from streamlit.testing.v1 import AppTest
from api_unit import app
from data_unit import baca_unit, ringkas_per_sektor
logging.getLogger("streamlit.runtime.scriptrunner_utils.script_run_context").setLevel(logging.ERROR)
langsung = ringkas_per_sektor(baca_unit()) # jalur 1: modul data
lewat_api = {r["sektor"]: r for r in TestClient(app).get("/ringkasan").json()} # jalur 2: API
dasbor = AppTest.from_file("app_streamlit.py", default_timeout=30).run()
print(f"{'sektor':8} {'unit':>4} {'rata-rata (modul)':>18} {'API':>8} {'dasbor':>8} cocok")
semua_cocok = True
for sektor, r in langsung.items():
dasbor.selectbox[0].select(sektor).run() # jalur 3: dasbor Streamlit
metrik = {m.label: m.value for m in dasbor.metric}
cocok = (f"{r['rata']:.1f}" == f"{lewat_api[sektor]['rata']:.1f}" == metrik["Rata-rata nilai"]
and str(r["jumlah"]) == metrik["Jumlah unit"])
semua_cocok &= cocok
print(f"{sektor:8} {r['jumlah']:>4} {r['rata']:>18.1f} {lewat_api[sektor]['rata']:>8.1f} "
f"{metrik['Rata-rata nilai']:>8} {'ya' if cocok else 'TIDAK'}")
print("\nsemua jalur konsisten:", semua_cocok)sektor unit rata-rata (modul) API dasbor cocok
hutan 3 35.2 35.2 35.2 ya
kebun 4 257.5 257.5 257.5 ya
sawah 2 1115.2 1115.2 1115.2 ya
kolam 3 369.3 369.3 369.3 ya
kandang 3 383.8 383.8 383.8 ya
semua jalur konsisten: True
Kelima sektor cocok di ketiga jalur (kolom terakhir “ya”, dan semua jalur konsisten: True). Pemeriksaan semacam ini adalah tes yang murah dan menyelamatkan: bila suatu hari satu jalur mulai menghitung lain, Anda tahu sebelum pengguna tahu.
Latihan
- Pada
app_streamlit.py, tambahkanst.slider“Nilai minimal” yang menyaring tabel. Tambahkan pemeriksaan di02_streamlit_interaksi.pyyang menggeser slider dan membaca jumlah baris tabel. - Pada
api_unit.py, tambahkan titik aksesGET /sektoryang mengembalikan daftar nama sektor, lalu uji denganTestClient. - Pada
app_dash.py, tambahkan komponen kedua (misalnyadcc.RadioItemsuntuk memilih urutan naik atau turun) dan callback-nya. Uji lewat klien uji Flask. - Tantangan: buat
app_gradio.pymenerima nomor unit (D1,L2, …) dan mengembalikan unit itu dengan memanggil API FastAPI Anda lewatTestClient(jangan lewat jaringan). Tampilkan galat yang ramah bila nomornya tidak ada.
Ringkasan Bab
- Streamlit: skrip dari atas ke bawah, dijalankan ulang tiap interaksi; uji dengan
AppTesttanpa peramban. - Gradio: membungkus fungsi/model dengan kotak otomatis; uji fungsinya langsung atau lewat
gradio_client. Jangan pakaishare=Trueuntuk data yang bukan untuk umum. - Dash: tata letak + callback, berdiri di atas Flask; uji dengan klien uji.
- folium dan pydeck menghasilkan peta HTML interaktif yang memuat pustakanya dari internet saat dibuka.
- HTMX + Flask: server mengirim potongan HTML; yang teruji di sini hanya sisi server.
- FastAPI: API JSON dengan validasi dan dokumentasi otomatis; uji dengan
TestClient. - Menyajikan = menjalankan di mesin yang bisa dijangkau pengguna; pertimbangkan data, rahasia, dan perawatan lebih dulu.
Penutup Seri I5
Selamat! Anda menyelesaikan Seri I5, dan itu berarti Tingkat Intermediate (Jilid 2) tamat. Dari kode yang rapi dan terawat (Seri I1), data dan internet (I2), statistik dan pemodelan (I3), bahasa Python tingkat lanjut (I4), sampai aplikasi pertama (I5), Anda sekarang bisa:
- menulis kode yang rapi, diuji, dan terkontrol versinya;
- mengambil, membersihkan, dan menganalisis data, termasuk statistik dasar;
- memakai generator, decorator, type hint, Pydantic, dan async;
- membuat API web, memproses data spasial, dan menampilkannya di peta;
- mengotomasi tugas berulang, membuat alat baris perintah, dan menyajikan data lewat dasbor.
Tetapi aplikasi Anda masih berjalan di komputer sendiri. Agar ia cepat, aman, bisa di-server untuk banyak pengguna, menangani data besar, dan cerdas, Anda perlu masuk ke Tingkat Advanced: rekayasa perangkat lunak profesional, data skala besar, machine learning, sampai AI.
Selanjutnya, di Bab 44: performa. Kita belajar mengukur dan mempercepat kode (profiling, struktur data yang tepat, vektorisasi, dan caching).