Neden önemli?
Anadolu Market her gece mağaza sistemlerinden gelen satış dosyalarını merkez veritabanına yükler. Bu iş bir kez yazılıp yıllarca her gece çalışır; bu yüzden "çalışıyor" yetmez: dosya yarım gelirse ne olur, aynı dosya iki kez gelirse mükerrer kayıt oluşur mu, bozuk satır tüm yüklemeyi durdurur mu, sabah ekip neyin yüklendiğini nereden görür? Bu derste bir veri boru hattı (data pipeline) kurarken sorulan bu soruların standart cevaplarını öğreneceksin. ETL = Extract (çıkar), Transform (dönüştür), Load (yükle).
Dosya düzeni ve pathlib
Klasik düzen üç klasördür: gelen/ (ham dosyalar bırakılır), islenen/ (başarıyla yüklenenler taşınır), hatali/ (işlenemeyenler ve hata kuyruğu). pathlib.Path ile klasörleri oluştur, dosyaları desenle bul, taşı. Pyodide'de bellek içi dosya sistemi olduğu için aynı kod tarayıcıda da çalışır.
from pathlib import Path
import shutil
KOK = Path('etl_demo')
GELEN, ISLENEN, HATALI = KOK / 'gelen', KOK / 'islenen', KOK / 'hatali'
for k in (GELEN, ISLENEN, HATALI):
k.mkdir(parents=True, exist_ok=True)
(GELEN / 'satis_2024-05-01.csv').write_text(
'siparis_no,sehir,tutar,adet\n1001,İzmir,149.90,2\n1002,Bursa,80.00,1\n', encoding='utf-8')
(GELEN / 'satis_2024-05-02.csv').write_text(
'siparis_no,sehir,tutar,adet\n1003,Ankara,abc,1\n1004,İzmir,20.5,3\n', encoding='utf-8')
(GELEN / 'notlar.txt').write_text('bu dosya ETL konusu değil')
for p in sorted(GELEN.glob('satis_*.csv')):
print(p.name, p.stem, p.suffix, p.stat().st_size, 'bayt')
shutil.move(str(GELEN / 'notlar.txt'), str(HATALI / 'notlar.txt'))
print('hatali:', [p.name for p in HATALI.iterdir()])
satis_2024-05-01.csv gibi ISO tarihli adlar hem sıralamayı hem "bu dosya daha önce yüklendi mi?" sorusunu kolaylaştırır. İş tarihi (business date) ile dosyanın geliş zamanını karıştırma; ikisini de kaydet.Extract ve şema doğrulama
Çıkarma adımı ham satırları okur; dönüştürme adımı her satırı şemaya göre doğrular ve tipler. Bozuk satır tüm işi durdurmamalı; hata kuyruğuna (dead-letter queue) yazılıp iş devam etmeli. Her hata kaydı: kaynak dosya, satır numarası, ham veri, hata nedeni.
import csv, io
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
@dataclass(frozen=True)
class Satis:
siparis_no: int
sehir: str
tutar: Decimal
adet: int
SEHIRLER = {'İzmir', 'Bursa', 'Ankara', 'İstanbul'}
def dogrula(ham: dict) -> Satis:
try:
no = int(ham['siparis_no'])
tutar = Decimal(ham['tutar'])
adet = int(ham['adet'])
except (KeyError, ValueError, InvalidOperation) as e:
raise ValueError(f'tip hatası: {e}') from e
if tutar < 0: raise ValueError('tutar negatif')
if adet <= 0: raise ValueError('adet pozitif olmalı')
sehir = ham['sehir'].strip()
if sehir not in SEHIRLER: raise ValueError(f'bilinmeyen şehir: {sehir}')
return Satis(no, sehir, tutar, adet)
def extract_transform(metin: str, kaynak: str):
gecerli, hatalar = [], []
for i, ham in enumerate(csv.DictReader(io.StringIO(metin)), start=2): # 1 = başlık
try:
gecerli.append(dogrula(ham))
except ValueError as e:
hatalar.append({'kaynak': kaynak, 'satir': i, 'ham': ham, 'neden': str(e)})
return gecerli, hatalar
metin = 'siparis_no,sehir,tutar,adet\n1003,Ankara,abc,1\n1004,İzmir,20.5,3\n1005,Mars,5,1\n'
g, h = extract_transform(metin, 'satis_2024-05-02.csv')
print('geçerli:', g)
for x in h:
print('HATA', x['satir'], x['neden'])
Decimal'e çevir, veritabanına kuruş cinsinden tam sayı ya da metin olarak yaz.Load: idempotent yükleme
İdempotent yükleme, aynı dosyayı iki kez çalıştırınca sonucun değişmemesi demektir. İki araç: tabloda doğal anahtar üzerinde UNIQUE kısıtı ve INSERT ... ON CONFLICT DO UPDATE (upsert); ayrıca hangi dosyanın yüklendiğini tutan bir yukleme_kaydi tablosu. Yükleme bir işlem (transaction) içinde yapılır: ya hepsi ya hiçbiri.
import sqlite3
from decimal import Decimal
def semayi_kur(con):
con.executescript('''
CREATE TABLE IF NOT EXISTS satis (
siparis_no INTEGER PRIMARY KEY, sehir TEXT NOT NULL,
tutar_kurus INTEGER NOT NULL, adet INTEGER NOT NULL, kaynak TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS yukleme_kaydi (
kaynak TEXT PRIMARY KEY, satir INTEGER, hata INTEGER, yuklendi_utc TEXT);
''')
def load(con, satislar, kaynak, hata_sayisi):
with con: # transaction: hata olursa geri alınır
con.executemany('''
INSERT INTO satis (siparis_no, sehir, tutar_kurus, adet, kaynak)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(siparis_no) DO UPDATE SET
sehir=excluded.sehir, tutar_kurus=excluded.tutar_kurus,
adet=excluded.adet, kaynak=excluded.kaynak
''', [(s.siparis_no, s.sehir, int(s.tutar * 100), s.adet, kaynak) for s in satislar])
con.execute('''INSERT INTO yukleme_kaydi VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(kaynak) DO UPDATE SET satir=excluded.satir, hata=excluded.hata,
yuklendi_utc=excluded.yuklendi_utc''', (kaynak, len(satislar), hata_sayisi))
con = sqlite3.connect(':memory:')
semayi_kur(con)
veri = [Satis(1001, 'İzmir', Decimal('149.90'), 2), Satis(1002, 'Bursa', Decimal('80.00'), 1)]
load(con, veri, 'satis_2024-05-01.csv', 0)
load(con, veri, 'satis_2024-05-01.csv', 0) # ikinci çalıştırma: mükerrer yok
print(con.execute('SELECT COUNT(*), SUM(tutar_kurus) FROM satis').fetchone())
print(con.execute('SELECT kaynak, satir FROM yukleme_kaydi').fetchall())
with con: bloğu istisna durumunda ROLLBACK yapar. Dosyayı islenen/ klasörüne taşıma işlemini yalnızca commit başarılı olduktan sonra yap; yoksa veritabanında olmayan bir dosya "işlendi" görünür.logging ile izlenebilirlik
print yerine logging: seviye (INFO/WARNING/ERROR), zaman damgası, modül adı, ve üretimde dosyaya ya da merkezi log sistemine yönlendirme. Her çalıştırma için bir çalıştırma kimliği (run id) üret; sabah ekip "dün gece 03:00 çalıştırması" diye arar.
import logging, io, uuid
akis = io.StringIO()
logging.basicConfig(stream=akis, level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s: %(message)s', force=True)
log = logging.getLogger('etl.satis')
run_id = uuid.UUID(int=12345) # gerçekte uuid.uuid4()
log.info('başladı run_id=%s', run_id)
log.info('dosya=%s geçerli=%d hata=%d', 'satis_2024-05-02.csv', 1, 2)
log.warning('hata kuyruğuna yazıldı: %d satır', 2)
log.error('dosya okunamadı: %s', 'satis_2024-05-03.csv')
print(akis.getvalue())
| Adım | Girdi | Çıktı | Hata durumunda |
|---|---|---|---|
| Extract | gelen/*.csv | ham satır listesi | Dosya hatali/ klasörüne, ERROR log |
| Transform | ham satırlar | Satis nesneleri + hata kuyruğu | Satır kuyruğa, WARNING log, iş devam |
| Load | Satis nesneleri | DB satırları + yükleme kaydı | ROLLBACK, dosya gelen/ içinde kalır |
| Arşiv | işlenen dosya | islenen/ klasörü | Yalnızca commit sonrası |
Uçtan uca: Anadolu Market günlük satış ETL'i
import sqlite3, shutil, json, logging, io
from pathlib import Path
def calistir(kok: Path, con, log):
gelen, islenen, hatali = kok / 'gelen', kok / 'islenen', kok / 'hatali'
for k in (gelen, islenen, hatali): k.mkdir(parents=True, exist_ok=True)
ozet = []
for dosya in sorted(gelen.glob('satis_*.csv')):
gecerli, hatalar = extract_transform(dosya.read_text(encoding='utf-8'), dosya.name)
if hatalar:
(hatali / (dosya.stem + '.hatalar.jsonl')).write_text(
'\n'.join(json.dumps(h, ensure_ascii=False) for h in hatalar), encoding='utf-8')
load(con, gecerli, dosya.name, len(hatalar))
shutil.move(str(dosya), str(islenen / dosya.name))
log.info('%s: %d yüklendi, %d hatalı', dosya.name, len(gecerli), len(hatalar))
ozet.append((dosya.name, len(gecerli), len(hatalar)))
return ozet
akis = io.StringIO()
logging.basicConfig(stream=akis, level=logging.INFO, format='%(levelname)s %(message)s', force=True)
con = sqlite3.connect(':memory:'); semayi_kur(con)
print(calistir(Path('etl_demo'), con, logging.getLogger('etl')))
print('kalan gelen:', [p.name for p in Path('etl_demo/gelen').iterdir()])
print(con.execute('SELECT sehir, SUM(tutar_kurus)/100.0 FROM satis GROUP BY sehir').fetchall())
print(akis.getvalue().strip())
Zamanlama: cron ve Airflow
Betiği her gece çalıştırmak için en basit araç cron'dur: 0 3 * * * /opt/etl/.venv/bin/python -m etl.satis satırı her gün 03:00'te çalıştırır. Boru hattı büyüyünce (bağımlılıklar, yeniden deneme, izleme, geriye dönük çalıştırma) Airflow, Prefect ya da Dagster gibi orkestratörler kullanılır: her adım bir görev (task), görevler yönlü çevrimsiz çizge (DAG) oluşturur, başarısız görev tek başına yeniden denenir. İdempotent yazılmış bir ETL, bu araçlarla sorunsuz çalışır; idempotent olmayan ise her yeniden denemede mükerrer kayıt üretir.
Tam yükleme mi, artımlı yükleme mi?
Derste her gün gelen dosyayı yükledin. Kaynak bir veritabanı ya da API olduğunda iki yol var: her gece tüm tabloyu çekmek (tam yükleme, full load) ya da yalnız son çalıştırmadan beri değişenleri çekmek (artımlı yükleme, incremental load). Küçük tablolarda tam yükleme basit ve güvenlidir. Milyonlarca satırda artımlı yükleme şarttır; bunun için bir su seviyesi işareti (watermark, high-water mark) tutulur: "en son hangi guncellenme zamanına kadar aldım?"
| Yöntem | Nasıl? | Artısı | Dikkat |
|---|---|---|---|
| Tam yükleme | Hedefi boşalt, hepsini yükle | Basit, silinen kayıtlar da yansır | Yavaş; yüklerken tablo boş kalmasın (geçici tablo + yer değiştir) |
| Artımlı (watermark) | WHERE guncellenme > :son | Hızlı, kaynağa az yük | Kaynakta silinen satırları görmez; aynı zaman damgasındaki satırlar kaçabilir |
| Değişiklik yakalama (CDC) | Veritabanı günlüğünden ekle/güncelle/sil olaylarını oku | Silmeler dahil her değişiklik | Ek altyapı (Debezium vb.) |
| Yavaş değişen boyut (SCD 2) | Değişen kaydı kapat, yeni sürüm aç | Geçmiş korunur ("Mart'ta müşteri hangi şehirdeydi?") | Sorgular geçerlilik tarihini hesaba katmalı |
Watermark'ı yalnızca yükleme başarıyla bittikten sonra ilerlet; aksi hâlde yarım kalan çalıştırmanın kayıtları sonsuza kadar kaçar. Aynı zaman damgasına sahip satırlar için (guncellenme, id) çiftini watermark olarak tutmak ya da >= ile çekip upsert ile tekrarları zararsız yapmak iki yaygın çözümdür.
import sqlite3, json
kaynak = sqlite3.connect(":memory:")
kaynak.execute("CREATE TABLE musteri(id INTEGER PRIMARY KEY, sehir TEXT, guncellenme TEXT)")
kaynak.executemany("INSERT INTO musteri VALUES (?,?,?)", [
(1, "İzmir", "2024-05-01T10:00"), (2, "Bursa", "2024-05-01T12:00"),
(3, "Ankara", "2024-05-02T09:00")])
durum = {"watermark": "2024-05-01T11:00"} # gerçekte dosya/tabloda saklanır
def artimli_cek(con, son):
return con.execute("SELECT id, sehir, guncellenme FROM musteri "
"WHERE guncellenme > ? ORDER BY guncellenme", (son,)).fetchall()
yeni = artimli_cek(kaynak, durum["watermark"])
print("çekilen:", [r[0] for r in yeni])
# ... yükleme başarılı olduktan SONRA:
if yeni:
durum["watermark"] = yeni[-1][2]
print(json.dumps(durum))
Büyük dosyalar: akış ve parti
10 GB'lık bir CSV'yi list(csv.DictReader(f)) ile belleğe almak sunucuyu düşürür. Üreteçlerle (iteratorler-uretecler dersi) her adım bir sonrakine satır satır akar; bellekte aynı anda yalnız bir parti durur. Veritabanına da satır satır değil parti parti (batch) yazılır: executemany ile 500-5000 satırlık partiler, her parti bir işlem (transaction) içinde. Python 3.12'deki itertools.batched bu işi tek satıra indirir.
import csv, io, sqlite3
from itertools import batched
ham = "no,tutar\n" + "\n".join(f"{i},{i * 1.5}" for i in range(1, 2501))
def oku(metin): # gerçekte: open(yol, newline="", encoding="utf-8")
yield from csv.DictReader(io.StringIO(metin))
def donustur(satirlar):
for s in satirlar:
yield int(s["no"]), round(float(s["tutar"]) * 100) # kuruş
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE satis(no INTEGER PRIMARY KEY, tutar_kurus INTEGER)")
parti_sayisi = 0
for parti in batched(donustur(oku(ham)), 1000):
with con: # her parti ayrı işlem: ya hep ya hiç
con.executemany("INSERT OR REPLACE INTO satis VALUES (?, ?)", parti)
parti_sayisi += 1
print(parti_sayisi, "parti,", con.execute("SELECT COUNT(*), SUM(tutar_kurus) FROM satis").fetchone())
pd.read_csv(yol, chunksize=100_000) ile gelir: her döngüde bir DataFrame parçası alırsın. Sütunlu ve sıkıştırılmış biçim için ara katmanda CSV yerine Parquet kullan (pip install pyarrow, df.to_parquet("satis.parquet")); hem 5-10 kat küçük hem tipleri korur. Kurulumda pyarrow tekerleği bulunamazsa Python sürümünün desteklendiğini kontrol et ve pip'i güncelle: python -m pip install -U pip.Veri kalitesi kapıları
Boru hattı hata vermeden bitti diye veri doğru değildir. Kaynak sistem bir gece sütun sırasını değiştirir, tutarlar kuruş yerine lira gelir ya da dosya yarım kesilir. Yükleme sonrası (ya da yayına almadan önce) küçük kontroller çalıştır ve eşik aşılırsa işi başarısız say:
- Satır sayısı mutabakatı: kaynaktaki satır = yüklenen + reddedilen. Fark varsa satır kaybolmuştur.
- Boşluk oranı: zorunlu sütunda
Noneoranı eşiğin (ör. %1) üstündeyse dur. - Tekillik: anahtar sütunda tekrar olmamalı.
- Aralık ve küme: tutar 0 ile makul bir üst sınır arasında, şehir bilinen listede.
- Tazelik: en yeni kaydın tarihi beklenen iş gününden eski olmamalı.
- Hacim: bugünkü satır sayısı son 7 günün ortalamasının yarısından azsa kaynakta sorun olabilir.
Bu kontroller büyüyünce Great Expectations, Soda ya da dbt testleri gibi araçlara taşınır (orkestrasyon dersindeki kalite kapıları); fikir aynıdır.
Atomik yazma ve dosya parmak izi
Çıktı dosyasını doğrudan hedef ada yazarken iş yarıda kesilirse, sonraki adım yarım bir dosya okur. Doğru yol: önce aynı klasörde geçici bir dosyaya yaz, tamamlanınca os.replace(gecici, hedef) ile adını değiştir. Aynı dosya sistemi içinde ad değiştirme atomiktir: okuyan ya eski dosyayı ya yeni dosyayı görür, yarısını asla. Gelen dosyalar için de parmak izi (SHA-256 özeti) tut: aynı içerik farklı adla ikinci kez gelirse tanırsın.
import hashlib, os, tempfile
from pathlib import Path
def atomik_yaz(yol: Path, metin: str) -> None:
fd, gecici = tempfile.mkstemp(dir=yol.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(metin)
f.flush()
os.fsync(f.fileno()) # diske gerçekten yazıldığından emin ol
os.replace(gecici, yol) # atomik ad değiştirme
except BaseException:
os.unlink(gecici)
raise
def parmak_izi(yol: Path) -> str:
h = hashlib.sha256()
with open(yol, "rb") as f:
for parca in iter(lambda: f.read(65536), b""):
h.update(parca)
return h.hexdigest()
klasor = Path("etl_cikti"); klasor.mkdir(exist_ok=True)
atomik_yaz(klasor / "rapor.csv", "sehir,ciro\nİzmir,1250\n")
atomik_yaz(klasor / "rapor_kopya.csv", "sehir,ciro\nİzmir,1250\n")
print(parmak_izi(klasor / "rapor.csv") == parmak_izi(klasor / "rapor_kopya.csv"))
print(sorted(p.name for p in klasor.iterdir()))
os.replace aynı dosya sistemi içinde atomiktir; geçici dosyayı hedefle aynı klasörde oluşturmanın nedeni budur (/tmp başka bir diskte olabilir). Windows'ta hedef dosya başka bir programda (ör. Excel'de) açıksa PermissionError alırsın; raporu açık tutan kullanıcılar için yeniden deneme ya da tarihli dosya adı kullan.Derste kalite kapılarını bir kontrol listesi olarak gördün. Bu bölüm onları uygulamaya döküyor: kalitenin boyutlarını ölçmek, kuralları koddan ayırıp bir şemaya yazmak, kötü satırları karantinaya almak, üretici ile tüketici arasındaki beklentiyi bir veri sözleşmesine bağlamak ve her çalıştırmanın sonunda okunabilir bir rapor üretmek. Örnekler gerçek kirli tablolarla: Anadolu Market'in raw_signups kayıt dökümü ve Power Query'den tanıdığın kirli_veri bayi raporu.
Kalitenin boyutları
| Boyut | Soru | raw_signups'ta örnek |
|---|---|---|
| Tamlık (completeness) | Zorunlu alanlar dolu mu? | 10 satırda şehir boş |
| Tekillik (uniqueness) | Anahtar tekrar ediyor mu? | 52 satır, 40 benzersiz id |
| Geçerlilik (validity) | Değer biçime ve kurala uyuyor mu? | Tarih kimi satırda 29.07.2024, kiminde 2024-02-10 |
| Tutarlılık (consistency) | Aynı şey hep aynı mı yazılmış? | ISTANBUL, istanbul, İstanbul |
| Doğruluk (accuracy) | Değer gerçeği yansıtıyor mu? | Kaynakla mutabakat gerekir; tek başına ölçülemez |
| Tazelik (timeliness) | Veri yeterince güncel mi? | En yeni kayıt beklenen günden eski mi? |
Her boyutu bir orana çevirmek (ör. tamlık = zorunlu alanları dolu satır / tüm satırlar) kaliteyi zaman içinde izlenebilir kılar: "bu hafta tamlık %98'den %81'e düştü" demek, "veride sorun var" demekten çok daha işe yarar.
import veri, re
from collections import Counter
r = veri.kayitlar("anadolu_market", "raw_signups")
n = len(r)
tamlik = sum(x["city"] is not None for x in r) / n
tekillik = len({x["id"] for x in r}) / n
gecerlilik = sum(bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", x["signup_date"])) for x in r) / n
yazimlar = Counter(x["city"] for x in r if x["city"])
print(f"satır={n} tamlık={tamlik:.1%} tekillik={tekillik:.1%} ISO tarih={gecerlilik:.1%}")
print("şehir yazımları:", len(yazimlar), sorted(yazimlar)[:4])
Kurallar koddan ayrı: şema
Kuralları if yığınları olarak yazmak yerine bir şemada (schema) toplarsan hem okunur hem yeniden kullanılır. Python dünyasında bunun en yaygın aracı pandera'dır (pandas/polars DataFrame şeması); satır bazlı API girdisinde ise pydantic. Mantık aynı: sütun → tür + kurallar.
import pandera.pandas as pa # pip install pandera
sema = pa.DataFrameSchema({
"id": pa.Column(int, unique=True),
"email": pa.Column(str, pa.Check.str_matches(r"^[^@\s]+@[^@\s]+\.[a-z]+$")),
"city": pa.Column(str, pa.Check.isin(["İstanbul", "Ankara", "İzmir"]), nullable=False),
"signup_date": pa.Column("datetime64[ns]"),
}, strict=True) # şemada olmayan sütun gelirse hata
try:
sema.validate(df, lazy=True) # lazy: ilk hatada durma, hepsini topla
except pa.errors.SchemaErrors as e:
print(e.failure_cases) # hangi sütun, hangi kural, hangi değer
Karantina tablosu: ne at, ne durdur
Kötü satırla ne yapmalı? Sessizce atmak veri kaybettirir ve kimse fark etmez; tüm partiyi durdurmak tek bir bozuk satır için bütün raporu bekletir. Orta yol karantinadır (quarantine): geçen satırlar hedefe, kalanlar ham hâliyle ve neden bilgisiyle ayrı bir tabloya yazılır. Veri sahibi karantinayı inceler, kaynağı düzeltir, satırlar yeniden işlenir. Karantina oranı eşiği aşarsa (ör. %5) parti yine durdurulur; bu, derste gördüğün hata kuyruğunun veritabanındaki karşılığıdır.
import sqlite3, json
c = sqlite3.connect(":memory:")
c.execute("CREATE TABLE karantina (parti TEXT, kayit TEXT, nedenler TEXT)")
bayi = veri.kayitlar("kirli_veri", "bayi")[2:] # ilk iki satır başlık
for satir in bayi:
nedenler = []
if not satir["Column1"]:
nedenler.append("bayi boş")
if satir["Column4"] in (None, "-"):
nedenler.append("tutar yok")
if satir["Column3"] and not re.fullmatch(r"\d{2}\.\d{2}\.\d{4}", satir["Column3"]):
nedenler.append("tarih geçersiz")
if nedenler:
c.execute("INSERT INTO karantina VALUES (?, ?, ?)",
("2024-01", json.dumps(satir, ensure_ascii=False), ", ".join(nedenler)))
for parti, nedenler in c.execute("SELECT parti, nedenler FROM karantina"):
print(parti, "|", nedenler)
Veri sözleşmesi (data contract)
Kaynak ekip bir gün sütun adını değiştirir ve senin boru hattın kırılır. Veri sözleşmesi, üretici ile tüketici arasında bu beklentileri yazıya döker: şema, kalite kuralları, tazelik taahhüdü (SLA), sahip ve iletişim. Çoğunlukla YAML'dır, git'te durur, değişiklikleri kod incelemesinden (code review) geçer ve CI'da otomatik denetlenir.
# contracts/raw_signups.yaml
dataset: anadolu_market.raw_signups
owner: crm-ekibi@anadolu.example
sla: {tazelik_saat: 24}
columns:
- {name: id, type: int, required: true, unique: true}
- {name: email, type: str, required: true, pattern: "^[^@ ]+@[^@ ]+$"}
- {name: city, type: str, required: true, allowed: [İstanbul, Ankara, İzmir]}
- {name: signup_date, type: str, required: true, pattern: "^\\d{4}-\\d{2}-\\d{2}$"}
quality:
max_karantina_orani: 0.05
Sözleşmeyi koda çevirmek, şema doğrulayıcını YAML'dan beslemek demektir: yaml.safe_load ile oku, her sütun tanımını bir kurala dönüştür, sonuçları bir rapora topla. Raporun sonunda tek bir karar olmalı: GEÇTİ ya da KALDI. Bu raporu JSON olarak saklamak, kalitenin zaman içindeki eğilimini panoda göstermeyi sağlar.
" Ankara" ve "izmir " gibi değerler gerçekte geçerlidir; yalnız yazım bozuk. Doğrulamadan önce kırpma, büyük/küçük harf ve tarih biçimi gibi güvenli dönüşümleri yap; ama "anlamı tahmin eden" düzeltmeleri (boş şehri "İstanbul" saymak) asla otomatik yapma.Üretim ETL'inde olgunluk işaretleri
İyi bir boru hattı; yapılandırmayı koddan ayırır (ortam değişkeni, TOML), her çalıştırma için metrik üretir (satır sayısı, süre, hata oranı) ve bu metrikleri bir panoya ya da uyarı sistemine gönderir. Hata oranı bir eşiği aşarsa (örneğin satırların yüzde biri) yükleme durdurulur; sessizce yüzde kırk hatalı dosya yüklemek, hiç yüklememekten kötüdür.
Veri kalitesi kontrolleri (data quality checks) yüklemeden sonra çalışır: bugünün toplamı dünün yüzde ellisinden azsa uyar, şehir listesinde bilinmeyen değer varsa uyar. Great Expectations ve pandera bu kontrolleri bildirimsel yazmanı sağlar. Büyük hacimde satır satır Python yerine pandas (ya da Polars) ile vektörel dönüştürme ve to_sql/COPY ile toplu yükleme yapılır; ancak hata kuyruğu, idempotency ve log disiplini aynı kalır.
Geriye dönük çalıştırma (backfill) için betik tarih parametresi almalı: python -m etl.satis --tarih 2024-05-01. Bugünün tarihini kodun içinde date.today() ile sabitlemek, bir hafta sonra "geçen pazartesiyi yeniden yükle" ihtiyacını imkânsız kılar.