Neden önemli?
Anadolu Market her pazartesi 12 mağaza müdürüne haftalık satış raporu, her ay 40.000 müşteriye kişiselleştirilmiş kampanya e-postası gönderir. Bunları print ve metin birleştirmeyle üretmeye çalışırsan kod ile görünüm birbirine dolanır: tasarımcı bir tabloya sütun eklemek istediğinde Python kodunu değiştirmen gerekir, müşteri adında bir < karakteri sayfayı bozar. Çözüm şablon (template) kullanmaktır: görünüm bir şablonda, veri Python'da durur; şablon motoru ikisini birleştirir (render).
Metin biçimlendirme ve rapor üretme dersinde f-string ve format ile küçük raporlar yazdın. Bu derste üç basamak çıkıyoruz: standart kütüphanedeki string.Template, endüstri standardı Jinja2 (Flask, Ansible, dbt, Airflow ve sayısız raporlama aracı onu kullanır) ve bunlarla HTML e-posta, HTML ve Markdown rapor üreten bir boru hattı (pipeline). Jinja2 tarayıcı motorunda hazır; kendi bilgisayarında pip install jinja2.
| Araç | Mantık (döngü, koşul) | Güvenli mi? | Ne zaman? |
|---|---|---|---|
| f-string | Python içinde | Şablon koda gömülü, dışarıdan gelemez | Kısa, tek satırlık mesajlar ve loglar |
str.format | Yok | Kullanıcı şablonunda tehlikeli | Eski kod; şablon sabitse |
string.Template | Yok | Evet, yalnızca ad değiştirir | Kullanıcının düzenlediği basit metinler (SMS, bildirim) |
| Jinja2 | Var: for, if, makro, kalıtım | autoescape ile evet | HTML rapor, e-posta, yapılandırma dosyası, belge |
string.Template: güvenli ve basit
string.Template yer tutucuları $ad biçiminde yazar (bitişik metin gerekiyorsa süslü parantezli biçim de vardır: ${ad}TL). Yalnızca ad değiştirir; ifade, nitelik erişimi ya da çağrı yapmaz. Bu yüzden şablonu pazarlama ekibinin bir yönetim panelinden düzenlemesine izin verebilirsin.
from string import Template
sms = Template("Sayın $ad, $tutar TL tutarlı siparişiniz $sehir şubesine ulaştı. Kod: $kod")
print(sms.substitute(ad="Ayşe", tutar="1.249,90", sehir="İzmir", kod="AM-1042"))
eksik = {"ad": "Mehmet", "tutar": "89,90"}
print(sms.safe_substitute(eksik)) # eksikler olduğu gibi kalır
try:
sms.substitute(eksik)
except KeyError as e:
print("Eksik alan:", e)
print(Template("İndirim: %$oran").substitute(oran=15), Template("Fiyat: $$100").substitute())
substitute eksik anahtarda KeyError verir (üretimde tercih edilir: eksik veriyle mesaj gitmesin); safe_substitute eksik yer tutucuyu olduğu gibi bırakır (taslak önizleme için). Gerçek bir $ işareti için $$ yazılır.
f-string ve format'ın sınırı: şablon kimden geliyor?
f-string harikadır ama şablon kodun içinde yazılmak zorundadır; dosyadan ya da veritabanından okunan bir metni f-string'e çeviremezsin. Akla gelen ilk çözüm str.format'tır; ama şablonu kullanıcı yazıyorsa bu bir güvenlik açığıdır, çünkü format alanı nitelik ve indeks erişimine izin verir:
class Musteri:
def __init__(self, ad):
self.ad = ad
self._kart_no = "4543 1234 5678 9012" # gizli olmalı
m = Musteri("Ayşe")
print("Merhaba {m.ad}".format(m=m)) # beklenen kullanım
print("Merhaba {m._kart_no}".format(m=m)) # kötü niyetli şablon
print("{m.__class__.__init__.__globals__[Musteri]}".format(m=m)) # içeriye sızma
str.format, eval ya da f-string'e dönüştürüp çalıştırma. Basit metin için string.Template, mantık gerekiyorsa Jinja2'nin SandboxedEnvironment'ını kullan.Jinja2 temelleri: değişken, filtre, for, if
Jinja üç tür işaretleme kullanır: {{ ... }} ifade yazdırır, {% ... %} komut çalıştırır (for, if, set, macro), {# ... #} yorumdur. Sözlük anahtarına ve nesne niteliğine aynı noktalı yazımla erişirsin: {{ s.tutar }}.
from jinja2 import Environment
env = Environment(trim_blocks=True, lstrip_blocks=True)
sablon = env.from_string("""Haftalık özet - {{ magaza|upper }}
{% for s in siparisler %}
{{ loop.index }}. {{ s.no }} {{ s.musteri|default("Misafir") }} {{ "%.2f"|format(s.tutar) }} TL{% if s.tutar > 1000 %} *{% endif %}
{% else %}
Bu hafta sipariş yok.
{% endfor %}
Toplam: {{ siparisler|sum(attribute="tutar")|round(2) }} TL ({{ siparisler|length }} sipariş)
""")
veri = [{"no": "AM-1", "musteri": "Ayşe", "tutar": 1249.9},
{"no": "AM-2", "musteri": None, "tutar": 89.5},
{"no": "AM-3", "tutar": 450}]
print(sablon.render(magaza="Kadıköy", siparisler=veri))
print(sablon.render(magaza="Bornova", siparisler=[]))
Burada birçok şey bir arada: loop.index 1'den sayar (loop.index0, loop.first, loop.last, loop.length da var), {% else %} liste boşsa çalışır, trim_blocks ve lstrip_blocks komut satırlarının bıraktığı boş satırları ve girintileri temizler. default filtresi tanımsız değeri yakalar; None için default("Misafir", true) yazman gerekir (çıktıdaki "None"a dikkat).
| Filtre | Örnek | Sonuç |
|---|---|---|
upper, lower, title, capitalize | {{ "çay"|upper }} | ÇAY |
default(x, true) | {{ none_deger|default("-", true) }} | - |
round(n), int, float | {{ 3.14159|round(2) }} | 3.14 |
format | {{ "%.2f"|format(5) }} | 5.00 |
length, sum, min, max | {{ liste|sum(attribute="tutar") }} | Toplam |
join, sort, unique, groupby | {{ adlar|sort|join(", ") }} | Sıralı liste |
e (escape), safe, striptags | {{ yorum|e }} | HTML kaçışlı |
Kendi filtren: Türk lirası biçimi
Hazır filtreler İngilizce biçim kullanır (1,249.90). Türkiye'de beklenen 1.249,90 TL'dir. Bir Python fonksiyonunu env.filters sözlüğüne eklersen şablonda filtre olarak kullanırsın. Filtre, şablon derlenmeden önce kaydedilmelidir.
from jinja2 import Environment
def tl(deger, ondalik=2):
metin = f"{deger:,.{ondalik}f}" # 1,249.90
return metin.translate(str.maketrans(",.", ".,")) + " TL" # 1.249,90 TL
env = Environment()
env.filters["tl"] = tl
print(env.from_string("Ciro: {{ ciro|tl }} | Ortalama: {{ ort|tl(0) }}").render(ciro=182450.5, ort=1249.9))
Makro ve kalıtım: tekrarı kaldırmak
Makro (macro) şablon içindeki fonksiyondur: aynı rozeti, butonu ya da tablo satırını tekrar tekrar yazmazsın. Kalıtım (template inheritance) ise tüm raporların ortak iskeletini (logo, CSS, alt bilgi) bir taban şablonda tutar; alt şablonlar yalnızca {% block %} bölümlerini doldurur. Gerçek projede şablonlar templates/ klasöründe dosya olarak durur ve FileSystemLoader("templates") ile yüklenir; burada aynı şeyi DictLoader ile bellekte yapıyoruz.
from jinja2 import Environment, DictLoader
sablonlar = {
"taban.html": """<html><body>
<h1>Anadolu Market | {% block baslik %}Rapor{% endblock %}</h1>
{% block icerik %}{% endblock %}
<footer>Otomatik üretildi</footer>
</body></html>""",
"makrolar.html": """{% macro rozet(durum) -%}
<span class="rozet {{ durum }}">{{ {"teslim": "Teslim", "kargoda": "Yolda"}.get(durum, "Bekliyor") }}</span>
{%- endmacro %}""",
"kargo.html": """{% extends "taban.html" %}
{% from "makrolar.html" import rozet %}
{% block baslik %}Kargo durumu{% endblock %}
{% block icerik %}<ul>
{% for g in gonderiler %}<li>{{ g.no }} {{ rozet(g.durum) }}</li>
{% endfor %}</ul>{% endblock %}""",
}
env = Environment(loader=DictLoader(sablonlar), autoescape=True)
print(env.get_template("kargo.html").render(gonderiler=[{"no": "AM-1", "durum": "teslim"}, {"no": "AM-2", "durum": "hazir"}]))
{% extends %} alt şablonun ilk komutu olmalıdır; alt şablonda block dışında yazılan metin görünmez. Taban bloğun içeriğini silmeden üstüne eklemek için {{ super() }} kullanılır.autoescape ve XSS
HTML üretirken en büyük tehlike XSS (cross-site scripting): kullanıcıdan gelen bir metnin (ürün yorumu, müşteri adı) içindeki <script> etiketi, raporu açan yöneticinin tarayıcısında çalışır. Jinja'da autoescape=True değişkenlerden gelen < > & " ' karakterlerini HTML varlıklarına çevirir; şablonun kendi etiketlerine dokunmaz.
from jinja2 import Environment, select_autoescape
yorum = '<script>alert("hack")</script> Çok güzel & hızlı'
tehlikeli = Environment().from_string("<p>{{ y }}</p>").render(y=yorum)
guvenli = Environment(autoescape=True).from_string("<p>{{ y }}</p>").render(y=yorum)
print(tehlikeli)
print(guvenli)
env = Environment(autoescape=select_autoescape(["html", "xml"])) # dosya uzantısına göre
print(env.from_string("{{ x|safe }}").render(x="<b>güvendiğin HTML</b>"))
Environment()'ın varsayılanı autoescape kapalıdır (Jinja HTML dışında YAML, SQL, Markdown da ürettiği için). HTML üreten her ortamda açıkça aç. |safe filtresi kaçışı kapatır: yalnızca senin ürettiğin, kullanıcı girdisi içermeyen HTML için kullan.
Boru hattı: CSV → özet → HTML rapor
Şimdi parçaları birleştirelim: ham CSV satırlarını oku, şehre göre özetle, Jinja ile satır içi CSS'li (e-posta istemcileri harici CSS'i çoğunlukla yok sayar) bir HTML tablo üret. Bu kalıp her haftalık raporun iskeletidir; bitirme projesinde bunu tam bir otomasyona dönüştüreceksin.
import csv, io
from collections import defaultdict
from jinja2 import Environment
CSV = """sehir,urun,tutar
İzmir,Çay,450
Ankara,Kahve,1249.9
İzmir,Kahve,890
İstanbul,Çay,2100
Ankara,Su,75.5
"""
ozet = defaultdict(lambda: {"adet": 0, "ciro": 0.0})
for satir in csv.DictReader(io.StringIO(CSV)):
ozet[satir["sehir"]]["adet"] += 1
ozet[satir["sehir"]]["ciro"] += float(satir["tutar"])
satirlar = sorted(({"sehir": s, **d} for s, d in ozet.items()), key=lambda r: -r["ciro"])
env = Environment(autoescape=True, trim_blocks=True, lstrip_blocks=True)
env.filters["tl"] = lambda x: f"{x:,.2f}".translate(str.maketrans(",.", ".,")) + " TL"
rapor = env.from_string("""<h2 style="color:#1a5">Şehir bazında satış</h2>
<table style="border-collapse:collapse">
{% for r in satirlar %}
<tr><td>{{ r.sehir }}</td><td>{{ r.adet }}</td><td style="text-align:right">{{ r.ciro|tl }}</td></tr>
{% endfor %}
<tr><th>Toplam</th><th>{{ satirlar|sum(attribute="adet") }}</th><th>{{ satirlar|sum(attribute="ciro")|tl }}</th></tr>
</table>""").render(satirlar=satirlar)
print(rapor)
Aynı veriden Markdown rapor (GitHub, Teams, Notion, Confluence okur) üretmek de bir şablon değişikliğidir. Markdown HTML olmadığı için burada autoescape kapalı kalır:
from jinja2 import Environment
env = Environment(trim_blocks=True, lstrip_blocks=True)
md = env.from_string("""# {{ baslik }}
| Şehir | Sipariş | Ciro |
|---|---:|---:|
{% for r in satirlar %}
| {{ r.sehir }} | {{ r.adet }} | {{ "%.2f"|format(r.ciro) }} |
{% endfor %}
**En iyi şehir:** {{ (satirlar|first).sehir }}
""")
print(md.render(baslik="43. hafta", satirlar=[{"sehir": "İstanbul", "adet": 1, "ciro": 2100.0},
{"sehir": "İzmir", "adet": 2, "ciro": 1340.0}]))
HTML raporu e-postaya koymak Ofis ve iş akışı otomasyonu dersindeki EmailMessage ile tek satırdır: msg.set_content(duz_metin) ardından msg.add_alternative(html, subtype="html"). Hem düz metin hem HTML sürümü göndermek spam filtrelerinde puanını iyileştirir ve HTML göstermeyen istemcilerde de okunur kalır.
{{ musteri.ad }} yerine {{ musteri.isim }} yazdı; varsayılan Undefined sessizce boş metin bastığı için 40.000 kişiye "Merhaba ," diye başlayan e-posta gitti. Çözüm: Environment(undefined=StrictUndefined) ile tanımsız değişkende hata almak ve gönderimden önce şablonu örnek veriyle render eden bir test eklemek.StrictUndefined ve şablon testleri
Varsayılan davranışta tanımsız değişken boş metin olur. Üretim ortamında StrictUndefined kullan: hata erken ve yüksek sesle gelsin. Şablonları birim testiyle denetle: örnek veriyle render et, beklenen parçaların çıktıda olduğunu (ya da tüm çıktının kayıtlı bir "anlık görüntüyle" aynı olduğunu, snapshot testing) doğrula.
Dosya düzeni ve önbellek
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
KLASOR = Path(__file__).parent / "templates" # templates/taban.html, templates/haftalik.html
env = Environment(
loader=FileSystemLoader(KLASOR),
autoescape=select_autoescape(["html", "xml"]),
undefined=StrictUndefined,
trim_blocks=True, lstrip_blocks=True,
)
html = env.get_template("haftalik.html").render(ozet=ozet)
Path("cikti/haftalik.html").write_text(html, encoding="utf-8")
Environment'ı bir kez oluştur ve yeniden kullan: derlenen şablonlar önbelleğe (cache) alınır. Her render'da yeni Environment kurmak gereksiz yere yavaştır.
Kullanıcı şablonları ve sandbox
Müşterilerin kendi şablonunu yazdığı bir üründe jinja2.sandbox.SandboxedEnvironment kullanılır: alt çizgili niteliklere ve tehlikeli metotlara erişimi engeller. Yine de zaman aşımı ve çıktı boyutu sınırı gibi ek önlemler gerekir.
PDF ve Word
HTML raporu PDF'e çevirmek için weasyprint (HTML(string=html).write_pdf("rapor.pdf")), Word şablonunu Jinja sözdizimiyle doldurmak için docxtpl kullanılır. İkisi de kendi bilgisayarında çalışır (pip install weasyprint docxtpl); mantık bu dersteki şablonlarla aynıdır.