Files
joppe/main.py
T
seppedl 1639f05e6e Voeg Pandas bestandsformaat-converter demo toe
Programma dat CSV, Excel, JSON, vaste-breedte tekst, pickle en
Python-snippets onderling converteert via een Pandas DataFrame.
Demonstreert dtype-afleiding, datetime-parsing met tijdzonebeheer
(UTC-normalisatie) en het omzetten naar een lijst-van-dicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:47:23 +02:00

599 lines
23 KiB
Python

"""
Pandas Bestandsformaat-Converter — Demo voor Python developers
=====================================================================
Dit programma leest een databestand in één van vier formaten (vaste-breedte
tekst, Excel, JSON, CSV) in een Pandas DataFrame en schrijft het terug naar
één van diezelfde vier formaten. Het invoerformaat wordt afgeleid uit de
extensie van het invoerbestand, en het uitvoerformaat uit de extensie van
het uitvoerbestand.
Gebruik:
uv run main.py <invoerbestand> <uitvoerbestand>
Voorbeelden:
uv run main.py sample.txt sample.csv
uv run main.py sample.csv sample.xlsx
uv run main.py sample.xlsx sample.json
uv run main.py sample.json sample2.txt # schrijft vaste breedte
uv run main.py sample.csv sample.pkl # pickle van lijst-van-dicts
uv run main.py sample.csv sample.py # Python-snippet met ingebedde data
Ondersteunde extensies invoer:
.txt -> vaste-breedte tekst
.csv -> kommagescheiden waarden
.xlsx -> Excel-werkmap
.json -> JSON (records-oriëntatie)
.pkl -> pickle van een lijst-van-dicts of een DataFrame
Ondersteunde extensies uitvoer:
.txt -> vaste-breedte tekst
.csv -> kommagescheiden waarden
.xlsx -> Excel-werkmap
.json -> JSON (records-oriëntatie)
.pkl -> pickle van een lijst-van-dicts
.py -> plakbaar Python-fragment met DATA = [...]
Hoe Pandas de conversie afhandelt
---------------------------------
Een Pandas DataFrame is een 2-dimensionale, in-memory, gelabelde tabel —
zie het als een spreadsheet die in Python leeft. De truc die formaat-
conversie eenvoudig maakt, is dat Pandas I/O ontkoppelt van het datamodel:
bestand op schijf --[lezer]--> DataFrame --[schrijver]--> bestand op schijf
Zodra de data in een DataFrame staat, maakt het niet meer uit waar ze
vandaan komt. Elke lezer produceert hetzelfde soort object, en elke
schrijver accepteert datzelfde object. Conversie is dus gewoon: "lees
in een DataFrame, schrijf het DataFrame uit in het nieuwe formaat."
Hier gebruikte Pandas-lezers:
* pd.read_fwf(pad, colspecs=..., names=..., dtype=...)
Vaste-breedte bestanden hebben geen scheidingsteken; elke kolom
bezet een specifiek karakterbereik. `colspecs` is een lijst van
(start, eind) tuples (eind-exclusief), `names` levert de
kolomkoppen (omdat het bestand er geen heeft), en `dtype` vertelt
Pandas welk Python/NumPy-type elke kolom moet krijgen.
* pd.read_csv(pad)
CSV is het eenvoudigste formaat. Pandas leidt de types (int,
float, string) automatisch per kolom af.
* pd.read_excel(pad)
Leest .xlsx via de `openpyxl`-engine (een extra dependency).
Pandas behandelt types vergelijkbaar met CSV.
* pd.read_json(pad, orient="records")
JSON kan veel verschillende vormen hebben; `orient="records"`
verwacht een lijst van objecten (`[{"kolom": waarde, ...}, ...]`),
wat de meest natuurlijke vertaling naar een DataFrame is.
Hier gebruikte Pandas-schrijvers:
* df.to_csv(pad, index=False, date_format=...)
`index=False` voorkomt dat Pandas de rijnummer-index als
extra kolom wegschrijft. `date_format` regelt hoe Timestamps
als tekst worden geserialiseerd.
* df.to_excel(pad, index=False)
Gebruikt onder de motorkap eveneens openpyxl voor .xlsx-uitvoer.
Datetimes worden als echte Excel-datumcellen weggeschreven.
* df.to_json(pad, orient="records", indent=2, date_format="iso")
`indent=2` maakt het bestand leesbaar voor mensen.
`date_format="iso"` voorkomt de standaard Unix-milliseconden.
* Vaste-breedte UITVOER heeft geen ingebouwde `to_fwf`. We bouwen het
handmatig: per kolom de maximale benodigde breedte bepalen
(kop versus waarden), en daarna elke cel opvullen met
str.ljust / str.rjust tot die breedte. Numerieke kolommen
worden rechts uitgelijnd; tekstkolommen links.
Waarom dtypes ertoe doen
------------------------
Wanneer je een vaste-breedte bestand inleest, is elke "cel" aanvankelijk
gewoon een stuk tekst. Als je Pandas niet vertelt welk type bedoeld is,
kan een kolom met "42" als string eindigen en later rekenkundige bewerkingen
breken. Daarom wordt het `dtype`-argument hieronder expliciet meegegeven
aan `read_fwf`. CSV/Excel/JSON-lezers doen redelijke inferentie zelf,
maar vaste breedte is dom over types — je moet het sturen.
Datums en tijden — verschillende formaten
-----------------------------------------
Datums leven in bestanden als gewone tekst, maar in een DataFrame willen we
ze als een echt `datetime64`-type zodat we erop kunnen sorteren, filteren,
optellen ("+1 dag"), enz. De brugfunctie is `pd.to_datetime(...)`.
Veelvoorkomende formaten in de praktijk:
* ISO 8601: "2026-05-08T14:30:00+02:00" (machine-vriendelijk)
* Dag eerst (NL/EU): "08/05/2026"
* Maand eerst (US): "05/08/2026"
* Met of zonder tijdzone-offset (`+02:00`, `Z`, of niets)
Belangrijkste argumenten van `pd.to_datetime`:
* `format=...` Expliciete strftime-string, bv. "%d/%m/%Y".
Snelste en betrouwbaarste optie.
* `format="mixed"` Pandas herkent het formaat per rij. Handig
wanneer dezelfde kolom meerdere formaten
bevat (bv. ISO én dd/mm/yyyy door elkaar).
* `dayfirst=True` Voor ambigue waarden zoals "01/02/2026"
("1 februari" i.p.v. "2 januari").
* `utc=True` Zet alles om naar UTC. Dit is cruciaal als
sommige rijen wél en andere géén tijdzone-
offset hebben — anders krijg je een mengsel
van naïeve en bewuste timestamps en kan je
niet vergelijken.
* `errors="coerce"` Onparseerbare waarden worden `NaT` (Not-a-
Time) i.p.v. een crash.
Tijdzones in 30 seconden
------------------------
Pandas onderscheidt **naïeve** en **tijdzone-bewuste** timestamps:
- Naïef: "2026-05-08 14:30:00" (geen offset, geen tz)
- Bewust: "2026-05-08 14:30:00+02:00" (heeft offset / zone)
Drie operaties dekken 95% van alle gebruik:
1. `pd.to_datetime(serie, utc=True)`
Eén stap: parse en zet om naar UTC. Inputs met offset worden
omgerekend; inputs zonder offset worden als UTC geïnterpreteerd.
Als je niets weet over het bronbestand, is dit altijd veilig.
2. `serie.dt.tz_localize("Europe/Brussels")`
Plak een tijdzone op een naïeve timestamp. Gebruik dit als je
weet dat lokale tijd bedoeld was maar de offset niet meegeschreven
is. Werkt enkel op naïeve timestamps.
3. `serie.dt.tz_convert("Europe/Brussels")`
Reken een bewuste timestamp om naar een andere tijdzone (de
absolute tijd verandert niet, alleen de weergave).
De gouden regel: **opslaan in UTC, weergeven in lokale tijd**. Daarom
roept dit programma `pd.to_datetime(..., utc=True)` aan in
`parse_datetimes()`. Wil je het uitvoerbestand in lokale tijd? Pas
`.dt.tz_convert("Europe/Brussels")` toe voordat je schrijft.
Datums zonder tijd (zoals `delivery_date` in dd/mm/yyyy) hebben in theorie
geen tijdzone nodig, maar Pandas heeft geen apart "date"-type: het wordt
een Timestamp op middernacht. Met `utc=True` is dat middernacht UTC. Voor
pure datums maakt dat verder weinig uit.
Van DataFrame naar lijst-van-dicts
-----------------------------------
Een DataFrame is handig voor berekeningen, maar soms wil je de data als
gewone Python-structuren: een lijst van dictionaries, waarbij elke dict
één rij voorstelt. Dit is de standaardvorm die je doorgeeft aan REST-APIs,
Jinja2-templates, unittest-fixtures, enz.
De sleutel is:
records = df.to_dict(orient="records")
Het resultaat is een list[dict], bv.:
[
{"id": 1, "name": "Widget", "price": 9.99, ...},
{"id": 2, "name": "Gadget", "price": 19.95, ...},
]
Terug de andere kant op — van lijst naar DataFrame — werkt via:
df = pd.DataFrame(records)
Hierbij leidt Pandas de kolomnamen en -types opnieuw af uit de dicts.
De `orient`-parameter bepaalt de structuur van de output:
orient="records" -> [{"col": val, ...}, ...] (rij per dict) *meest gebruikt*
orient="dict" -> {"col": {0: val, 1: val}} (kolom per dict, rij-index als sleutel)
orient="list" -> {"col": [val, val, ...]} (kolom per lijst)
orient="index" -> {0: {"col": val}, ...} (rij-index als sleutel)
orient="split" -> {"columns": [...], "data": [[...], ...]} (compacte vorm)
Voor de meeste dagelijkse situaties is `orient="records"` de juiste keuze:
het sluit het beste aan bij hoe mensen over rijen denken.
Aandacht voor Pandas-types in dicts
------------------------------------
`to_dict()` converteert waarden naar Python-types waar het kan, maar
sommige types blijven Pandas/NumPy-specifiek:
- int-kolommen -> numpy.int64 (werkt als int, maar type is anders)
- float-kolommen -> numpy.float64
- datetime-kolommen -> pandas.Timestamp (subklasse van datetime.datetime)
Dit kan problemen geven bij:
* json.dumps(records) — NumPy-types zijn niet JSON-serialiseerbaar.
* pickle — werkt prima, Timestamps zijn picklable.
* vergelijking met isinstance(v, int) — np.int64 is géén int.
Oplossing: converteer de records expliciet naar standaard Python-types:
import numpy as np
def to_plain_python(record: dict) -> dict:
result = {}
for k, v in record.items():
if isinstance(v, pd.Timestamp):
result[k] = v.to_pydatetime() # -> datetime.datetime
elif isinstance(v, (np.integer,)):
result[k] = int(v) # -> int
elif isinstance(v, (np.floating,)):
result[k] = float(v) # -> float
else:
result[k] = v
return result
records = [to_plain_python(r) for r in df.to_dict(orient="records")]
Dit programma past die conversie toe in `df_to_records()` — zodat zowel
pickle als het Python-snippet correcte standaard-types bevatten.
Pickle — binaire opslag van Python-objecten
--------------------------------------------
Pickle (.pkl) is Python's ingebouwde binaire serialisatieformaat. Het kan
vrijwel elk Python-object opslaan: lijsten, dicts, datetime-objecten, enz.
import pickle
# Wegschrijven:
with open("data.pkl", "wb") as f: # "wb" = write binary
pickle.dump(records, f)
# Inlezen:
with open("data.pkl", "rb") as f: # "rb" = read binary
records = pickle.load(f)
Voordelen:
+ Razendsnel (binair formaat, geen tekst-parsing)
+ Behoudt exacte Python-types inclusief datetime met tijdzone
+ Geschikt voor caching van tussenresultaten
Nadelen:
- Niet leesbaar voor mensen of andere programmeertalen
- Pickle-bestanden van de ene Python-versie werken mogelijk niet in
een andere (gebruik het dus niet als uitwisselingsformaat)
- Nooit een pickle inlezen van een onbekende bron — het kan
arbitraire code uitvoeren bij het laden (veiligheidsrisico)
Python-snippet (.py) — data direct in code
-------------------------------------------
Soms wil je testdata of fixtures rechtstreeks in een Python-bestand
inbedden, zodat je ze kunt importeren zonder een extern bestand nodig te
hebben. Dit programma genereert een `.py`-bestand met een `DATA`-variabele:
from datetime import datetime, timezone
DATA = [
{
"id": 1,
"name": "Widget",
"price": 9.99,
"quantity": 10,
"created_at": datetime(2026, 5, 8, 12, 30, 0, tzinfo=timezone.utc),
"delivery_date": datetime(2026, 5, 15, 0, 0, 0, tzinfo=timezone.utc),
},
...
]
Dit bestand kan je kopiëren in een project of importeren:
from sample import DATA
for row in DATA:
print(row["name"], row["created_at"])
Datetimes worden als `datetime(...)` uitgedrukt (niet als strings) zodat
de types meteen correct zijn bij importeren.
"""
from __future__ import annotations
import pickle
import sys
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
# Schema voor het voorbeeld-bestand met vaste breedte. Elke tuple is
# (start, eind_exclusief). Deze bereiken definiëren de karakterposities
# die elk veld inneemt.
FWF_COLSPECS = [(0, 5), (5, 25), (25, 35), (35, 45), (45, 72), (72, 85)]
FWF_NAMES = ["id", "name", "price", "quantity", "created_at", "delivery_date"]
FWF_DTYPES = {
"id": "int64",
"name": "string",
"price": "float64",
"quantity": "int64",
# Datetime-kolommen lezen we eerst als string en parsen we daarna
# uniform met pd.to_datetime — zo werkt dezelfde code voor alle formaten.
"created_at": "string",
"delivery_date": "string",
}
# Datetime-kolommen + hoe we ze terug naar tekst formatteren bij het
# wegschrijven naar vaste breedte. Bij het inlezen herkennen we het
# formaat automatisch (ISO of dd/mm/yyyy) via format="mixed".
DATETIME_COLUMNS: dict[str, str] = {
"created_at": "%Y-%m-%dT%H:%M:%S%z", # ISO 8601 met offset
"delivery_date": "%d/%m/%Y", # dag eerst (NL/EU)
}
def parse_datetimes(df: pd.DataFrame) -> pd.DataFrame:
"""Zet datetime-kolommen om naar tijdzone-bewuste UTC Timestamps.
Werkt voor alle invoerformaten:
- txt/csv/json: kolommen komen binnen als string en worden geparsed.
- xlsx: Excel parseert datums al zelf; we zorgen alleen dat
ze tijdzone-bewust (UTC) zijn.
`format="mixed"` laat Pandas per rij het formaat raden, zodat ISO
("2026-05-08T14:30:00+02:00") én dd/mm/yyyy ("15/05/2026") in dezelfde
kolom kunnen voorkomen — handig wanneer data uit verschillende bronnen
samengevoegd wordt.
`utc=True` is de sleutel voor tijdzone-veiligheid: alles wordt
genormaliseerd naar UTC, ongeacht of de input een offset had of niet.
"""
for col in DATETIME_COLUMNS:
if col not in df.columns:
continue
series = df[col]
# Als de kolom al datetime is (bv. uit Excel), alleen de tijdzone fixen.
if pd.api.types.is_datetime64_any_dtype(series):
if series.dt.tz is None:
df[col] = series.dt.tz_localize("UTC")
else:
df[col] = series.dt.tz_convert("UTC")
continue
# Anders: parse de strings. We "snuiven" of de waarden in
# dd/mm/yyyy-stijl staan (bevatten een schuine streep) of in
# ISO-stijl (yyyy-mm-dd). dayfirst=True is enkel veilig voor de
# eerste vorm — op ISO-strings zou het maand en dag verwisselen.
sample_values = series.dropna().astype(str)
looks_ddmm = len(sample_values) > 0 and sample_values.str.contains("/").any()
df[col] = pd.to_datetime(
series,
format="mixed",
dayfirst=looks_ddmm,
utc=True,
errors="coerce",
)
return df
def format_value(value: object, col: str) -> str:
"""Formatteer één celwaarde voor tekstuitvoer (vaste breedte)."""
if pd.isna(value):
return ""
if col in DATETIME_COLUMNS and isinstance(value, pd.Timestamp):
return value.strftime(DATETIME_COLUMNS[col])
return str(value)
def _to_plain_python(value: object) -> object:
"""Zet een Pandas/NumPy-waarde om naar een standaard Python-type.
`df.to_dict()` geeft soms numpy.int64, numpy.float64 of pd.Timestamp
terug. Die zijn niet JSON-serialiseerbaar en gedragen zich subtiel anders
dan de ingebouwde int/float/datetime. Deze functie normaliseert ze.
"""
if isinstance(value, pd.Timestamp):
return value.to_pydatetime() # pd.Timestamp -> datetime.datetime (met tz)
if isinstance(value, np.integer):
return int(value) # numpy.int64 -> int
if isinstance(value, np.floating):
return float(value) # numpy.float64 -> float
if isinstance(value, float) and np.isnan(value):
return None # NaN -> None (JSON-vriendelijk)
return value
def df_to_records(df: pd.DataFrame) -> list[dict]:
"""Zet een DataFrame om naar een lijst van gewone Python-dicts.
Stap 1: df.to_dict(orient="records") geeft [{"col": val, ...}, ...]
Stap 2: _to_plain_python() vervangt NumPy/Pandas-types door standaard
Python-types zodat pickle, json.dumps en isinstance() correct werken.
"""
raw = df.to_dict(orient="records")
return [{k: _to_plain_python(v) for k, v in row.items()} for row in raw]
def _datetime_repr(dt: datetime) -> str:
"""Genereer een leesbare Python-expressie voor een datetime-object."""
if dt.tzinfo is not None:
return (
f"datetime({dt.year}, {dt.month}, {dt.day}, "
f"{dt.hour}, {dt.minute}, {dt.second}, tzinfo=timezone.utc)"
)
return (
f"datetime({dt.year}, {dt.month}, {dt.day}, "
f"{dt.hour}, {dt.minute}, {dt.second})"
)
def _value_repr(value: object) -> str:
"""Genereer een Python-literal voor één waarde in het .py-snippet."""
if isinstance(value, datetime):
return _datetime_repr(value)
return repr(value)
def read_any(path: Path) -> pd.DataFrame:
"""Stuur door naar de juiste Pandas-lezer op basis van de bestandsextensie."""
ext = path.suffix.lower()
if ext == ".txt":
# Vaste breedte: kolomgrenzen, namen en dtypes moeten meegegeven worden.
# `skiprows=1` slaat de leesbare kopregel in het voorbeeldbestand over.
df = pd.read_fwf(
path,
colspecs=FWF_COLSPECS,
names=FWF_NAMES,
dtype=FWF_DTYPES,
skiprows=1,
)
elif ext == ".csv":
df = pd.read_csv(path)
elif ext == ".xlsx":
df = pd.read_excel(path)
elif ext == ".json":
df = pd.read_json(path, orient="records")
elif ext == ".pkl":
# Een pickle kan een lijst-van-dicts of een DataFrame zijn.
data = pickle.loads(path.read_bytes())
df = pd.DataFrame(data) if isinstance(data, list) else data
else:
raise ValueError(f"Niet-ondersteunde invoerextensie: {ext}")
return parse_datetimes(df)
def write_fwf(df: pd.DataFrame, path: Path) -> None:
"""Schrijf een DataFrame weg als een tekstbestand met vaste breedte.
Pandas heeft geen ingebouwde `to_fwf`, dus we doen het zelf: kies een
breedte per kolom (max van de kopbreedte en de langste waarde) en vul
daarna elke cel op. Numerieke kolommen worden rechts uitgelijnd,
tekstkolommen en datums links — dat is de gangbare opmaak voor data
met vaste breedte.
"""
widths: dict[str, int] = {}
for col in df.columns:
header_w = len(str(col))
formatted = df[col].apply(lambda v, c=col: format_value(v, c))
values_w = int(formatted.map(len).max() or 0)
widths[col] = max(header_w, values_w) + 2 # +2 voor wat ademruimte
def fmt_cell(value: object, col: str) -> str:
s = format_value(value, col)
if pd.api.types.is_numeric_dtype(df[col]):
return s.rjust(widths[col])
return s.ljust(widths[col])
lines: list[str] = []
header = "".join(
str(col).rjust(widths[col]) if pd.api.types.is_numeric_dtype(df[col])
else str(col).ljust(widths[col])
for col in df.columns
)
lines.append(header)
for _, row in df.iterrows():
lines.append("".join(fmt_cell(row[col], col) for col in df.columns))
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def write_pkl(df: pd.DataFrame, path: Path) -> None:
"""Schrijf de DataFrame weg als een pickle van een lijst-van-dicts.
We gebruiken df_to_records() zodat de pickle standaard Python-types
bevat (int, float, datetime) in plaats van NumPy/Pandas-specifieke types.
"""
records = df_to_records(df)
path.write_bytes(pickle.dumps(records))
def write_py(df: pd.DataFrame, path: Path) -> None:
"""Genereer een plakbaar Python-bestand met DATA = [...].
Het resultaat is een geldig .py-bestand dat je rechtstreeks kunt
importeren:
from sample import DATA
for row in DATA:
print(row["name"])
Datetimes worden als `datetime(...)` uitgedrukt zodat je ze kunt
gebruiken zonder extra parsing.
"""
records = df_to_records(df)
needs_datetime = any(
isinstance(v, datetime)
for row in records
for v in row.values()
)
lines: list[str] = ["# Auto-gegenereerd door main.py\n"]
if needs_datetime:
lines.append("from datetime import datetime, timezone\n")
lines.append("\nDATA = [\n")
for row in records:
lines.append(" {\n")
for key, value in row.items():
lines.append(f" {key!r}: {_value_repr(value)},\n")
lines.append(" },\n")
lines.append("]\n")
path.write_text("".join(lines), encoding="utf-8")
def write_any(df: pd.DataFrame, path: Path) -> None:
"""Stuur door naar de juiste Pandas-schrijver op basis van de bestandsextensie."""
ext = path.suffix.lower()
if ext == ".txt":
write_fwf(df, path)
elif ext == ".csv":
# ISO 8601 met offset is de veiligste "tekstvorm" voor datetimes.
df.to_csv(path, index=False, date_format="%Y-%m-%dT%H:%M:%S%z")
elif ext == ".xlsx":
# Excel-cellen kunnen geen tijdzone bevatten — drop tz vlak voor
# het schrijven, zodat openpyxl niet struikelt.
df_out = df.copy()
for col in DATETIME_COLUMNS:
if col in df_out.columns and pd.api.types.is_datetime64_any_dtype(df_out[col]):
if df_out[col].dt.tz is not None:
df_out[col] = df_out[col].dt.tz_convert("UTC").dt.tz_localize(None)
df_out.to_excel(path, index=False)
elif ext == ".json":
# date_format="iso" voorkomt Unix-milliseconden in de JSON.
df.to_json(path, orient="records", indent=2, date_format="iso")
elif ext == ".pkl":
write_pkl(df, path)
elif ext == ".py":
write_py(df, path)
else:
raise ValueError(f"Niet-ondersteunde uitvoerextensie: {ext}")
def main(argv: list[str]) -> int:
if len(argv) != 3:
print(__doc__)
print("FOUT: precies twee argumenten verwacht: <invoerbestand> <uitvoerbestand>")
return 1
in_path = Path(argv[1])
out_path = Path(argv[2])
if not in_path.exists():
print(f"FOUT: invoerbestand bestaat niet: {in_path}")
return 1
print(f"Bezig met lezen van {in_path} ({in_path.suffix}) ...")
df = read_any(in_path)
print(f"{len(df)} rijen geladen, {len(df.columns)} kolommen.")
print("Voorbeeld:")
print(df.head().to_string(index=False))
print(f"Bezig met schrijven van {out_path} ({out_path.suffix}) ...")
write_any(df, out_path)
print("Klaar.")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))