Bab 47 — Python Modern (3.12 sampai 3.14): Menulis Kode “Gaya Terbaru”

Seri A1 — Rekayasa Perangkat Lunak Profesional

CatatanDi bab ini, Anda akan:
  • Mengenal fitur baru Python 3.12, 3.13, dan 3.14 yang paling berguna
  • Menulis tipe generik dan alias tipe dengan sintaks modern
  • Memakai template string (t"...") dan anotasi tipe yang ditunda
  • Mengintip Python 3.15 (impor malas, frozendict, UTF-8 bawaan) yang dijalankan di interpreter terpisah
  • Memahami subinterpreter, mode free-threaded, dan JIT eksperimental (dan jujur soal apa yang belum Anda punya)
  • Menulis ulang proyek bersambung dengan “gaya terbaru” Python 3.14

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

Python seperti kebun yang terus ditanami varietas baru. Setiap versi tahunan membawa tanaman baru: sebagian langsung berguna di dapur, sebagian masih eksperimen di bedeng percobaan. Bab ini adalah “buku panduan tanaman baru” untuk Python 3.12, 3.13, dan 3.14. Semua fitur di bawah dibaca dari halaman resmi “What’s New in Python” (docs.python.org, dibaca 2026-10-10) dan dijalankan di Python 3.14.8, kecuali yang diberi tanda belum diuji.

Peta fitur utama Python 3.12 sampai 3.14.
PeringatanAWAS

Bab ini memakai sintaks yang hanya ada di versi tertentu: t"..." dan except A, B: butuh 3.14, class Kotak[T = int] butuh 3.13, sintaks [T] butuh 3.12. Skrip di bab ini tidak akan jalan di Python 3.11 atau lebih lama. Periksa dulu versi Anda dengan python --version.

Python 3.12: tipe dan f-string yang lebih luwes

01_python_312.py
# Bab 47 - Fitur Python 3.12: parameter tipe (PEP 695), pernyataan type, f-string leluasa (PEP 701), @override, batched
from itertools import batched
from typing import override


# PEP 695: tipe generik tanpa TypeVar terpisah
def terbesar[T: (int, float)](angka: list[T]) -> T:
    return max(angka)


type Daftar[T] = list[T]            # alias tipe dengan kata kunci "type"
type Koordinat = tuple[float, float]


class Hewan:
    def suara(self) -> str:
        return "..."


class Ayam(Hewan):
    @override                        # PEP 698: pemeriksa tipe memastikan ini benar-benar menimpa
    def suara(self) -> str:
        return "kukuruyuk"


diameter: Daftar[float] = [32.5, 45.2, 28.0]
titik: Koordinat = (-6.2, 106.8)
print("terbesar:", terbesar(diameter))
print("alias:", Daftar, "|", Koordinat)
print(Ayam().suara())

# PEP 701: f-string boleh memakai tanda kutip yang sama di dalam, dan banyak baris
jenis = ["Jati", "Meranti", "Mahoni"]
print(f"Jenis: {", ".join(jenis)}")
print(f"Tertua: {
    max(diameter)
} cm")

# itertools.batched: potong menjadi kelompok berukuran tetap
for kelompok in batched(range(1, 8), 3):
    print(kelompok)
terbesar: 45.2
alias: Daftar | Koordinat
kukuruyuk
Jenis: Jati, Meranti, Mahoni
Tertua: 45.2 cm
(1, 2, 3)
(4, 5, 6)
(7,)
  • Sintaks parameter tipe (PEP 695): def terbesar[T: (int, float)](...) mendeklarasikan tipe generik langsung di nama fungsi, tanpa TypeVar terpisah. Pernyataan type membuat alias (type Daftar[T] = list[T]).
  • @override (PEP 698): menandai metode yang menimpa metode induk, sehingga pemeriksa tipe memprotes bila nama induknya berubah.
  • f-string baru (PEP 701): boleh memakai tanda kutip yang sama di dalam kurung kurawal, dan boleh melintasi beberapa baris (lihat baris Jenis: dan Tertua:).
  • itertools.batched: memotong urutan menjadi kelompok berukuran tetap; kelompok terakhir boleh lebih pendek ((7,)).
  • Pesan error lebih ramah, dan modul distutils dihapus (pakai setuptools, seperti di Bab 45).

Python 3.13: lebih terdefinisi

02_python_313.py
# Bab 47 - Fitur Python 3.13: locals() terdefinisi (PEP 667), copy.replace, warnings.deprecated, default TypeVar (PEP 696)
import copy
import warnings
from dataclasses import dataclass


# PEP 667: locals() di dalam fungsi adalah "foto" (snapshot) yang berdiri sendiri
def coba_locals():
    x = 1
    foto = locals()
    foto["x"] = 99          # mengubah foto TIDAK mengubah variabel asli
    return x, locals()["x"]


print("x setelah mengubah locals():", coba_locals())


# copy.replace: salin objek dengan mengganti sebagian bidang
@dataclass(frozen=True)
class Pohon:
    jenis: str
    diameter_cm: float


p1 = Pohon("Jati", 32.5)
p2 = copy.replace(p1, diameter_cm=33.1)
print(p1, "->", p2)


# warnings.deprecated: menandai fungsi lama (juga terbaca oleh pemeriksa tipe)
@warnings.deprecated("pakai hitung_baru()")
def hitung_lama():
    return 1


with warnings.catch_warnings(record=True) as tangkapan:
    warnings.simplefilter("always")
    hitung_lama()
print("peringatan:", tangkapan[0].category.__name__, "-", tangkapan[0].message)


# PEP 696: parameter tipe boleh punya nilai bawaan
class Kotak[T = int]:
    def __init__(self, isi: T):
        self.isi = isi


print("Kotak default:", Kotak.__type_params__[0].has_default(), Kotak.__type_params__[0].__default__)
x setelah mengubah locals(): (1, 1)
Pohon(jenis='Jati', diameter_cm=32.5) -> Pohon(jenis='Jati', diameter_cm=33.1)
peringatan: DeprecationWarning - pakai hitung_baru()
Kotak default: True <class 'int'>
  • locals() terdefinisi (PEP 667): di dalam fungsi, ia mengembalikan foto variabel. Mengubah foto tidak mengubah variabel asli, sehingga x tetap 1.
  • copy.replace(): menyalin objek sambil mengganti sebagian bidang (cocok untuk dataclass frozen).
  • warnings.deprecated() (PEP 702): menandai fungsi lama; pemeriksa tipe tahu, dan saat dipanggil muncul DeprecationWarning.
  • Nilai bawaan untuk parameter tipe (PEP 696): class Kotak[T = int].
  • Fitur lain dari halaman resmi: REPL baru (penyuntingan multibaris), traceback berwarna, penghapusan 19 modul lama (PEP 594), dan dua perintis yang kita bahas di bawah: build free-threaded dan JIT (keduanya eksperimental di 3.13).

Python 3.14: template string

Template string (PEP 750) ditulis seperti f-string, tetapi dengan huruf t. Bedanya penting: hasilnya bukan teks, melainkan objek Template yang menyimpan bagian teks dan bagian isian terpisah, sehingga kode Anda bisa memeriksa atau membersihkan isian sebelum digabung.

03_template_string.py
# Bab 47 - Python 3.14: template string (PEP 750), huruf t di depan tanda kutip
from html import escape
from string.templatelib import Interpolation, Template

nama = "Jati <A>"
diameter = 32.5

t = t"Pohon {nama} berdiameter {diameter:.1f} cm"
print("tipe t-string :", type(t).__name__, "| apakah str?", isinstance(t, str))
print("bagian teks   :", t.strings)
print("bagian isian  :", [(i.expression, i.value, i.format_spec) for i in t.interpolations])


def ke_html(templat: Template) -> str:
    """Menggabungkan templat, TETAPI isian dilewatkan escape() lebih dulu (anti injeksi HTML)."""
    hasil = []
    for bagian in templat:
        if isinstance(bagian, Interpolation):
            hasil.append(escape(str(format(bagian.value, bagian.format_spec))))
        else:
            hasil.append(bagian)
    return "".join(hasil)


print("f-string      :", f"<li>{nama}</li>")
print("t-string aman :", ke_html(t"<li>{nama}</li>"))
tipe t-string : Template | apakah str? False
bagian teks   : ('Pohon ', ' berdiameter ', ' cm')
bagian isian  : [('nama', 'Jati <A>', ''), ('diameter', 32.5, '.1f')]
f-string      : <li>Jati <A></li>
t-string aman : <li>Jati &lt;A&gt;</li>

isinstance(t, str) bernilai False: ini bukan teks biasa. t.strings berisi tiga potongan teks tetap, dan t.interpolations berisi dua isian beserta ungkapan asal, nilai, dan format-nya (.1f). Fungsi ke_html meloloskan tiap isian lewat escape(). Hasil f-string <li>Jati <A></li> membiarkan karakter < dan > apa adanya (berbahaya bila nama berasal dari pengguna), sedangkan t-string aman menghasilkan &lt;A&gt;. Di proyek bersambung nanti, t-string dipakai untuk kueri SQL yang aman.

Python 3.14: anotasi tipe yang ditunda

Sebelum 3.14, anotasi seperti berikut: Simpul | None di dalam kelas Simpul memicu NameError (kelas belum selesai dibuat), sehingga orang menulis "Simpul" dengan tanda kutip atau from __future__ import annotations. Di 3.14 (PEP 649 dan 749), anotasi tidak dievaluasi di awal; baru dihitung saat diminta. Modul baru annotationlib membacanya dalam tiga format:

04_anotasi_ditunda.py
# Bab 47 - Python 3.14: anotasi tipe ditunda (PEP 649 dan 749) dan modul annotationlib
from annotationlib import Format, get_annotations


# Acuan maju (forward reference) tanpa tanda kutip: aman di 3.14
class Simpul:
    def __init__(self, nilai: int, berikut: Simpul | None = None):
        self.nilai = nilai
        self.berikut = berikut


print("anotasi Simpul.__init__:", get_annotations(Simpul.__init__, format=Format.STRING))


def fungsi(arg: NamaBelumAda) -> int:      # NamaBelumAda tidak pernah didefinisikan
    return 1


print("STRING     :", get_annotations(fungsi, format=Format.STRING))
bagian = get_annotations(fungsi, format=Format.FORWARDREF)
print("FORWARDREF :", type(bagian["arg"]).__name__, "->", bagian["arg"].__forward_arg__)
try:
    get_annotations(fungsi, format=Format.VALUE)
except NameError as galat:
    print("VALUE      : NameError:", galat)
anotasi Simpul.__init__: {'nilai': 'int', 'berikut': 'Simpul | None'}
STRING     : {'arg': 'NamaBelumAda', 'return': 'int'}
FORWARDREF : ForwardRef -> NamaBelumAda
VALUE      : NameError: name 'NamaBelumAda' is not defined
  • Kelas Simpul merujuk dirinya sendiri tanpa tanda kutip dan tidak error.
  • Format.STRING memberi teks anotasi apa adanya.
  • Format.FORWARDREF memberi objek ForwardRef untuk nama yang belum ada, jadi tidak error.
  • Format.VALUE (perilaku lama) menghasilkan NameError karena NamaBelumAda memang tak pernah didefinisikan.

Python 3.14: subinterpreter

Sebuah proses Python bisa menjalankan beberapa interpreter yang terisolasi, masing-masing dengan variabel global sendiri. PEP 734 menghadirkannya ke pustaka standar sebagai concurrent.interpreters. Dokumentasi resminya menyebut modul ini memberi isolasi; kemampuan menjalankan bersamaan datang dari menggabungkannya dengan thread, dan interpreter tidak berbagi GIL sehingga bisa memakai banyak inti CPU.

05_subinterpreter.py
# Bab 47 - Python 3.14: subinterpreter lewat concurrent.interpreters (PEP 734)
from concurrent import interpreters
from concurrent.futures import InterpreterPoolExecutor


def jumlah_kuadrat(n):
    return sum(i * i for i in range(n))


interp = interpreters.create()
try:
    # call(): jalankan fungsi di interpreter terpisah dan ambil hasilnya
    print("call:", interp.call(jumlah_kuadrat, 1000))

    # exec(): jalankan kode teks; tiap interpreter punya __main__ sendiri
    interp.exec("nilai_rahasia = 42")
    interp.exec("print('di dalam subinterpreter, nilai_rahasia =', nilai_rahasia)")
    print("di interpreter utama ada 'nilai_rahasia'?", "nilai_rahasia" in globals())

    # antrean untuk bertukar data antar-interpreter
    antrean = interpreters.create_queue()
    interp.prepare_main(antrean=antrean)
    interp.exec("antrean.put('halo dari subinterpreter')")
    print("dari antrean:", antrean.get())
finally:
    interp.close()

# Kolam pekerja berbasis interpreter, serupa ThreadPoolExecutor
if __name__ == "__main__":
    with InterpreterPoolExecutor(max_workers=2) as kolam:
        hasil = list(kolam.map(jumlah_kuadrat, [10, 100, 1000]))
    print("kolam interpreter:", hasil)
call: 332833500
di dalam subinterpreter, nilai_rahasia = 42
di interpreter utama ada 'nilai_rahasia'? False
dari antrean: halo dari subinterpreter
kolam interpreter: [285, 328350, 332833500]

nilai_rahasia ada di dalam subinterpreter tetapi tidak di interpreter utama (jawaban False): terisolasi. Data berpindah lewat antrean. InterpreterPoolExecutor adalah kolam pekerja bergaya ThreadPoolExecutor (Bab 35), hasilnya [285, 328350, 332833500].

CatatanINGAT

Dokumentasi menegaskan bahwa isolasi antar-interpreter “sebaik usaha” (best effort), bukan batas keamanan, dan tidak semua paket PyPI sudah mendukung banyak interpreter. Jangan memakainya untuk menjalankan kode yang tidak Anda percaya.

Free-threaded dan JIT: fitur yang jujur belum Anda punya

Dua fitur ini berkaitan dengan kecepatan, dan keduanya bergantung pada build Python yang terpasang. Karena itu skrip berikut keluarannya diambil sekali dari komputer penulis (@statis).

Mode free-threaded (tanpa GIL). Di 3.13 fitur ini eksperimental (PEP 703); di 3.14 ia didukung resmi tetapi masih opsional (PEP 779). Dokumentasi menyebut penalti kecepatan pada kode satu-thread kini sekitar 5 sampai 10 persen. Bila terpasang, di Windows Anda memanggilnya dengan py -V:3.14t, dan memasangnya dengan Python install manager: py install 3.14t (dokumentasi Windows, dibaca 2026-10-10).

06_free_threaded.py
# Bab 47 - Python 3.14: mode free-threaded (tanpa GIL), apakah terpasang di komputer ini?
import subprocess
import sys
import sysconfig

print("build free-threaded (Py_GIL_DISABLED):", sysconfig.get_config_var("Py_GIL_DISABLED"))
print("GIL aktif di Python ini:", sys._is_gil_enabled())

# Pada Windows, build free-threaded dipanggil lewat "py -V:3.14t"
hasil = subprocess.run(
    ["py", "-V:3.14t", "-c", "import sys; print('GIL aktif:', sys._is_gil_enabled())"],
    capture_output=True, text=True,
)
print("kode keluar py -V:3.14t:", hasil.returncode)
baris = (hasil.stdout or hasil.stderr).strip().splitlines()
print("pesan:", baris[0] if baris else "(kosong)")
build free-threaded (Py_GIL_DISABLED): 0
GIL aktif di Python ini: True
kode keluar py -V:3.14t: 103
pesan: No suitable Python runtime found

Jadi di komputer penulis build free-threaded belum terpasang: Python yang dipakai punya GIL aktif, dan py -V:3.14t menjawab “No suitable Python runtime found”. Kami tidak menguji perilaku free-threaded; apa yang ditulis tentangnya di sini berasal dari dokumentasi resmi, bukan dari percobaan kami. Bila Anda memasangnya, jalankan ulang skrip kode/bab35/06_gil_dan_cpu.py dengan py -V:3.14t dan bandingkan.

JIT eksperimental (PEP 744). JIT (just-in-time compiler) menerjemahkan bagian kode panas ke kode mesin saat program berjalan. Menurut dokumentasi 3.14, rilis biner resmi Windows dan macOS kini menyertakannya, tidak disarankan untuk produksi, dan dinyalakan dengan variabel lingkungan PYTHON_JIT=1; dampaknya pada kecepatan berkisar dari sekitar 10 persen lebih lambat sampai 20 persen lebih cepat tergantung beban kerja. Fungsi sys._jit membantu memeriksanya:

07_jit.py
# Bab 47 - JIT eksperimental (Python 3.13 dan 3.14): tersedia? aktif?
import os
import subprocess
import sys

print("JIT tersedia di Python ini:", sys._jit.is_available())
print("JIT aktif secara bawaan   :", sys._jit.is_enabled())

# JIT dinyalakan lewat variabel lingkungan PYTHON_JIT=1, jadi kita periksa di proses anak
anak = subprocess.run(
    [sys.executable, "-c", "import sys; print(sys._jit.is_enabled())"],
    capture_output=True, text=True, env=dict(os.environ, PYTHON_JIT="1"),
)
print("aktif bila PYTHON_JIT=1   :", anak.stdout.strip())
JIT tersedia di Python ini: True
JIT aktif secara bawaan   : False
aktif bila PYTHON_JIT=1   : True

Di komputer penulis JIT tersedia tetapi mati secara bawaan, dan menyala bila PYTHON_JIT=1. Kami tidak mengukur kecepatannya karena angka waktu tidak stabil (lihat Bab 44).

Pesan error yang makin ramah

Tiap versi memperbaiki pesan galat. Hasil asli dari Python 3.14:

08_pesan_error.py
# Bab 47 - Pesan error yang makin ramah (3.12 sampai 3.14), tampil apa adanya dari Python 3.14
import random
import traceback


def pesan_terakhir(galat):
    return traceback.format_exception_only(type(galat), galat)[-1].strip()


salah_sintaks = {
    "salah ketik kata kunci": "whille True:\n    pass\n",
    "elif setelah else": "if x:\n    pass\nelse:\n    pass\nelif y:\n    pass\n",
    "awalan string bentrok": "x = ub'abc'\n",
}
for judul, kode in salah_sintaks.items():
    try:
        compile(kode, "<contoh>", "exec")
    except SyntaxError as galat:
        print(f"{judul:28} -> {pesan_terakhir(galat)}")

for judul, ungkapan in [("kunci dict tak bisa di-hash", "{[1]: 2}"), ("elemen set tak bisa di-hash", "{1, [2]}")]:
    try:
        eval(ungkapan)
    except TypeError as galat:
        print(f"{judul:28} -> {pesan_terakhir(galat)}")

try:
    random.randit(1, 6)
except AttributeError as galat:
    print(f"{'salah ketik nama':28} -> {pesan_terakhir(galat)}")

# Python 3.14 (PEP 758): except boleh tanpa kurung bila tanpa "as"
try:
    int("abc")
except ValueError, TypeError:
    print("except ValueError, TypeError: tanpa kurung diterima di 3.14")
salah ketik kata kunci       -> SyntaxError: invalid syntax. Did you mean 'while'?
elif setelah else            -> SyntaxError: 'elif' block follows an 'else' block
awalan string bentrok        -> SyntaxError: 'u' and 'b' prefixes are incompatible
kunci dict tak bisa di-hash  -> TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
elemen set tak bisa di-hash  -> TypeError: cannot use 'list' as a set element (unhashable type: 'list')
salah ketik nama             -> AttributeError: module 'random' has no attribute 'randit'. Did you mean: 'randint'?
except ValueError, TypeError: tanpa kurung diterima di 3.14

Python sekarang menebak maksud Anda (Did you mean 'while'?, Did you mean: 'randint'?), menjelaskan kesalahan yang dulu samar ('elif' block follows an 'else' block), dan menyebut tipe sebenarnya dari kunci yang tak bisa di-hash. Baris terakhir memperlihatkan PEP 758: except ValueError, TypeError: tanpa kurung kini sah di 3.14 (bila tidak memakai as).

Dari halaman resmi 3.14, catatan lain yang berguna: pengumpul sampah incremental hadir di 3.14.0 sampai 3.14.4 lalu dikembalikan ke pengumpul sampah generasi 3.13 pada 3.14.5; ada modul compression.zstd (PEP 784); sys.remote_exec() dan python -m pdb -p PID untuk men-debug proses yang sedang berjalan (PEP 768).

Python 3.15

Python 3.15.0 dirilis pada 9 Oktober 2026. Buku ini tetap berpatokan ke Python 3.14; semua bab lain ditulis dan diuji di 3.14.8. Subbagian ini hanya “intipan” ke versi berikutnya. Fitur dipilih dari halaman resmi What’s New in Python 3.15 (docs.python.org/3.15/whatsnew/3.15.html, dibaca 2026-10-10) dan dijalankan di Python 3.15.0 yang kami pasang terpisah, tanpa menyentuh Python utama komputer penulis.

CatatanINGAT

Skrip 10 sampai 14 memakai arahan @python dengan jalur absolut ke Python 3.15 milik komputer penulis (pola yang sama dengan Bab 40). Jalur itu tidak ada di komputer Anda. Di komputer Windows Anda cukup memasang 3.15 lalu menjalankan py -3.15 nama_skrip.py. Skrip ini tidak akan jalan di Python 3.14 atau lebih lama karena memakai sintaks baru.

1. Impor malas (PEP 810). Kata lazy di depan import menunda pemuatan modul sampai modul itu benar-benar dipakai, seperti menyimpan benih di gudang dan baru membuka karungnya saat akan menanam. Program yang mengimpor banyak modul jadi lebih cepat mulai.

10_lazy_import.py
# Bab 47 - Python 3.15: lazy import (impor malas, PEP 810)
import sys

lazy import json
lazy from pathlib import Path

print("Python:", sys.version_info.major, sys.version_info.minor)
print("json sudah dimuat sebelum dipakai?", "json" in sys.modules)
teks = json.dumps({"jenis": "Jati", "diameter_cm": 32.5})
print("hasil json.dumps:", teks)
print("json sudah dimuat sesudah dipakai?", "json" in sys.modules)
Python: 3 15
json sudah dimuat sebelum dipakai? False
hasil json.dumps: {"jenis": "Jati", "diameter_cm": 32.5}
json sudah dimuat sesudah dipakai? True

Sebelum json.dumps dipanggil, json belum ada di sys.modules (jawaban False); setelah dipakai barulah termuat (True). Baris lazy from pathlib import Path tidak pernah dipakai, jadi pathlib tidak dimuat lewat baris itu. Dokumentasi menyebut lazy hanya boleh di tingkat modul, dan tidak boleh untuk from x import *.

2. frozendict (PEP 814) dan sentinel (PEP 661). frozendict adalah kamus yang tidak bisa diubah, sehingga bisa di-hash dan boleh jadi kunci dict. sentinel membuat nilai penanda unik, berguna ketika None sudah punya arti lain (misalnya “tinggi pohon sengaja dikosongkan”).

11_frozendict_sentinel.py
# Bab 47 - Python 3.15: frozendict (PEP 814) dan sentinel (PEP 661)
import copy

# frozendict: kamus yang tidak bisa diubah
batas = frozendict(Jati=30.0, Meranti=40.0)
print(batas)
try:
    batas["Mahoni"] = 25.0
except TypeError as galat:
    print("TypeError:", galat)
print("urutan kunci tidak memengaruhi hash:",
      hash(batas) == hash(frozendict(Meranti=40.0, Jati=30.0)))
kunci = {batas: "aturan plot A"}  # karena bisa di-hash, boleh jadi kunci dict
print("dipakai sebagai kunci:", kunci[frozendict(Jati=30.0, Meranti=40.0)])

# sentinel: nilai penanda yang unik
BELUM_DIUKUR = sentinel("BELUM_DIUKUR")
print(repr(BELUM_DIUKUR))
print("hasil copy tetap objek yang sama:", copy.copy(BELUM_DIUKUR) is BELUM_DIUKUR)

def tinggi(data, kunci, bawaan=BELUM_DIUKUR):
    return data.get(kunci, bawaan)

print(tinggi({"jati": None}, "jati"))
print(tinggi({}, "jati") is BELUM_DIUKUR)
frozendict({'Jati': 30.0, 'Meranti': 40.0})
TypeError: 'frozendict' object does not support item assignment
urutan kunci tidak memengaruhi hash: True
dipakai sebagai kunci: aturan plot A
BELUM_DIUKUR
hasil copy tetap objek yang sama: True
None
True

Mengubah frozendict memunculkan TypeError. Pada dua pencarian terakhir, None yang tersimpan di data (hasil None) tetap bisa dibedakan dari “kunci tidak ada” (BELUM_DIUKUR, hasil True).

3. Unpacking di dalam komprehensi (PEP 798). Tanda * dan ** kini boleh dipakai di depan ungkapan komprehensi untuk meratakan daftar atau menggabungkan kamus, tanpa dua perulangan bersarang.

12_unpack_komprehensi.py
# Bab 47 - Python 3.15: unpacking (*, **) di dalam komprehensi (PEP 798)
panen_per_blok = [[245.5, 310.0], [198.5], [276.0, 120.0]]

# Cara lama (3.14 ke bawah): dua perulangan bersarang
lama = [x for blok in panen_per_blok for x in blok]
# Cara baru: satu bintang
baru = [*blok for blok in panen_per_blok]
print(lama)
print(baru)
print("sama:", lama == baru)

pengaturan = [{"plot": "A", "umur": 5}, {"umur": 7}, {"tinggi": 12.5}]
gabung = {**p for p in pengaturan}
print(gabung)
print("jumlah semua:", sum(baru))
[245.5, 310.0, 198.5, 276.0, 120.0]
[245.5, 310.0, 198.5, 276.0, 120.0]
sama: True
{'plot': 'A', 'umur': 7, 'tinggi': 12.5}
jumlah semua: 1150.0

Dua cara menghasilkan daftar yang sama. Pada penggabungan kamus, kunci umur muncul dua kali dan nilai terakhir (7) menang, seperti {**a, **b} biasa.

4. UTF-8 menjadi encoding bawaan (PEP 686). Ini yang dijanjikan di Bab 13. Bila Anda memanggil open() tanpa encoding=, 3.15 memakai UTF-8 di semua sistem. Skrip di bawah menulis berkas UTF-8, lalu membukanya tanpa encoding= di dua proses 3.15: apa adanya, dan dengan PYTHONUTF8=0 (cara mematikan perilaku baru menurut dokumentasi).

13_utf8_bawaan.py
# Bab 47 - Python 3.15: UTF-8 menjadi encoding bawaan (PEP 686)
import os
import subprocess
import sys
from pathlib import Path

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "suhu315.txt"
berkas.write_text("Suhu kolam 28°C ±2", encoding="utf-8")

kode_anak = (
    "import sys, locale\n"
    f"f = open(r'{berkas}')\n"  # sengaja TANPA encoding=
    "print('encoding open():', f.encoding)\n"
    "print('mode UTF-8 (sys.flags.utf8_mode):', sys.flags.utf8_mode)\n"
    "print('locale.getencoding():', locale.getencoding())\n"
    "print('isi:', f.read())\n"
)

def jalankan(judul, **tambahan):
    lingkungan = {k: v for k, v in os.environ.items() if k != "PYTHONUTF8"}
    lingkungan["PYTHONIOENCODING"] = "utf-8"
    lingkungan.update(tambahan)
    hasil = subprocess.run([sys.executable, "-c", kode_anak],
                           capture_output=True, env=lingkungan)
    print("==", judul)
    print(hasil.stdout.decode("utf-8").rstrip())

jalankan("Python 3.15 apa adanya")
jalankan("dengan PYTHONUTF8=0 (perilaku lama)", PYTHONUTF8="0")
== Python 3.15 apa adanya
encoding open(): utf-8

mode UTF-8 (sys.flags.utf8_mode): 1

locale.getencoding(): cp1252

isi: Suhu kolam 28°C ±2
== dengan PYTHONUTF8=0 (perilaku lama)
encoding open(): cp1252

mode UTF-8 (sys.flags.utf8_mode): 0

locale.getencoding(): cp1252

isi: Suhu kolam 28°C ±2

Pada 3.15 apa adanya, open() memakai utf-8 meski locale.getencoding() di komputer penulis tetap cp1252: aturan bawaan kini tidak lagi mengikuti pengaturan Windows. Dengan PYTHONUTF8=0 kembali cp1252 dan muncul mojibake °. Di komputer Linux atau macOS hasilnya mungkin tidak berbeda karena locale-nya sudah UTF-8. Tetap tulislah encoding="utf-8" agar kode benar di semua versi.

5. Saran pada pesan error. Python 3.15 menambah saran pada AttributeError yang umum:

14_pesan_error_315.py
# Bab 47 - Python 3.15: saran pada pesan error
import traceback

def tampilkan(percobaan):
    try:
        percobaan()
    except AttributeError as galat:
        print("".join(traceback.format_exception_only(galat)).strip())

tampilkan(lambda: [1, 2, 3].push(4))
tampilkan(lambda: {}.put("a", 1))
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?
AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v.

Orang yang terbiasa dengan bahasa lain sering menulis push atau put; Python kini menunjuk .append dan d[k] = v.

Fitur 3.15 lain dari halaman resmi yang tidak kami jalankan: paket profiling dengan profiler sampling Tachyon (PEP 799), JIT yang ditingkatkan, re.prefixmatch(), tomllib untuk TOML 1.1, dan tambahan di bidang tipe dan C API. Bacalah halaman resminya bila Anda tertarik.

Proyek Bersambung A1: kode “gaya terbaru”

Kita tulis ulang bagian proyek dengan yang kita pelajari: dataclass(slots=True), kelas generik Repo[T] (sintaks 3.12), alias type Baris, dan t-string untuk kueri SQL berparameter:

09_proyek_gaya_terbaru.py
# Bab 47 - Proyek bersambung A1: kode "gaya terbaru" Python 3.14 (generik, t-string, slots)
import sqlite3
from dataclasses import dataclass
from string.templatelib import Interpolation, Template


@dataclass(frozen=True, slots=True)
class Pohon:
    jenis: str
    plot: str
    diameter_cm: float


type Baris = tuple[str, str, float]


class Repo[T]:
    """Penyimpan generik: isinya bisa tipe apa pun, ditulis dengan sintaks 3.12."""

    def __init__(self) -> None:
        self._isi: list[T] = []

    def tambah(self, item: T) -> None:
        self._isi.append(item)

    def semua(self) -> list[T]:
        return list(self._isi)


def kueri(templat: Template) -> tuple[str, list]:
    """Mengubah t-string menjadi (SQL berparameter, nilai). Nilai TIDAK pernah ditempel ke teks SQL."""
    sql, nilai = [], []
    for bagian in templat:
        if isinstance(bagian, Interpolation):
            sql.append("?")
            nilai.append(bagian.value)
        else:
            sql.append(bagian)
    return "".join(sql), nilai


repo: Repo[Pohon] = Repo()
for p in [Pohon("Jati", "A", 32.5), Pohon("Meranti", "A", 45.2), Pohon("Mahoni", "B", 28.0), Pohon("Jati", "B", 51.5)]:
    repo.tambah(p)

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE pohon (jenis TEXT, plot TEXT, diameter_cm REAL)")
baris: list[Baris] = [(p.jenis, p.plot, p.diameter_cm) for p in repo.semua()]
db.executemany("INSERT INTO pohon VALUES (?, ?, ?)", baris)

plot, minimal = "A", 30
sql, nilai = kueri(t"SELECT jenis, diameter_cm FROM pohon WHERE plot = {plot} AND diameter_cm > {minimal} ORDER BY diameter_cm")
print("SQL     :", sql)
print("nilai   :", nilai)
print("hasil   :", db.execute(sql, nilai).fetchall())

# Masukan jahat tetap hanya menjadi NILAI, bukan bagian dari perintah SQL
plot_jahat = "A' OR '1'='1"
sql, nilai = kueri(t"SELECT jenis FROM pohon WHERE plot = {plot_jahat}")
print("masukan jahat ->", db.execute(sql, nilai).fetchall())
SQL     : SELECT jenis, diameter_cm FROM pohon WHERE plot = ? AND diameter_cm > ? ORDER BY diameter_cm
nilai   : ['A', 30]
hasil   : [('Jati', 32.5), ('Meranti', 45.2)]
masukan jahat -> []

Fungsi kueri mengubah t-string menjadi SQL dengan tanda tanya ? dan daftar nilai terpisah. Kueri pertama mengembalikan dua pohon plot A berdiameter di atas 30 cm. Pada baris terakhir, masukan jahat A' OR '1'='1 tetap hanya menjadi nilai yang dicocokkan dengan kolom plot, jadi tidak ada baris yang cocok ([]) dan SQL tak berhasil disusupi. Itu persis kegunaan keamanan t-string, yang kita lanjutkan di Bab 49.

Latihan

  1. Ubah terbesar di 01_python_312.py menjadi type-alias Angka = int | float dan gunakan sebagai tipe parameter. Jalankan.
  2. Ubah t-string di skrip 03 agar diameter ditulis dengan :.0f. Amati kolom format_spec di keluaran, lalu jalankan ke_html pada t-string itu.
  3. Di terminal Anda sendiri, jalankan python -c "import sys; print(sys._jit.is_enabled())" sekali biasa, lalu sekali lagi dengan variabel PYTHON_JIT=1 (di PowerShell: $env:PYTHON_JIT=1). Bandingkan jawabannya dengan keluaran skrip 07.
  4. Tantangan: buat fungsi html_daftar(templat) yang menerima t-string berisi daftar nama petani dan menghasilkan <ul>...</ul> dengan semua isian di-escape, lalu uji dengan nama "Budi <script>".

Ringkasan Bab

  • 3.12: sintaks parameter tipe dan type, f-string luwes, @override, itertools.batched.
  • 3.13: locals() terdefinisi, copy.replace, warnings.deprecated, nilai bawaan parameter tipe; REPL baru.
  • 3.14: template string t"...", anotasi ditunda (annotationlib), subinterpreter, except A, B:, pesan error lebih ramah.
  • Free-threaded (didukung resmi di 3.14) dan JIT (eksperimental) bergantung pada build; di komputer penulis free-threaded belum terpasang dan tidak diuji.
  • 3.15 (dirilis 9 Oktober 2026; buku tetap berpatokan ke 3.14): lazy import, frozendict, sentinel, * dan ** di komprehensi, UTF-8 sebagai encoding bawaan; dijalankan dengan Python 3.15 terpisah.

Selanjutnya, di Bab 48: deployment, membawa aplikasi ke server dengan Docker, CI/CD, dan pemantauan.