From c0adc77f56a525cd33f34e4c4ed079fdb74db2ef Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 18 Oct 2026 08:43:45 +0200 Subject: [PATCH] initial load of categories --- .coverage | Bin 86016 -> 86016 bytes AGENTS.md | 4 + .../initial_load/categories/categories.txt | 35 +++++ .../categories/extract_categories.py | 54 ++++++++ tests/test_extract_categories.py | 125 ++++++++++++++++++ 5 files changed, 218 insertions(+) create mode 100644 scripts/initial_load/categories/categories.txt create mode 100644 scripts/initial_load/categories/extract_categories.py create mode 100644 tests/test_extract_categories.py diff --git a/.coverage b/.coverage index fc1003fc69531a601ce49aad94ca985623bd316b..4b61fab846ed083d4193d2ff030de8adc69d64d1 100644 GIT binary patch delta 13086 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 I23U&!KegsjivR!s delta 13053 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 F{eL$cKr8?N diff --git a/AGENTS.md b/AGENTS.md index 40449de..084d27b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,3 +144,7 @@ uv run pytest --cov=src --cov-report=term-missing ``` **Note**: Some comparisons between `date | None` types require explicit guards or type assertions to satisfy mypy, even when the logic is correct. See `src/domain_utils.py` for examples. + +## Code Style + +- Don't be too verbose in docstrings and comments diff --git a/scripts/initial_load/categories/categories.txt b/scripts/initial_load/categories/categories.txt new file mode 100644 index 0000000..e2dc377 --- /dev/null +++ b/scripts/initial_load/categories/categories.txt @@ -0,0 +1,35 @@ +Girokonto:Beruf / Ausbildung +Girokonto:Beruf / Ausbildung:Gehalt +Girokonto:Beruf / Ausbildung:Reisekosten +Girokonto:Freizeit +Girokonto:Freizeit:Abo +Girokonto:Freizeit:Elektronik&Hardware +Girokonto:Freizeit:Foto +Girokonto:Freizeit:Medien +Girokonto:Freizeit:Musikintrumente +Girokonto:Freizeit:Urlaub&Ausflüge +Girokonto:Freizeit:sonst. +Girokonto:Lebenshaltung +Girokonto:Lebenshaltung:Auto +Girokonto:Lebenshaltung:Bekleidung +Girokonto:Lebenshaltung:Fahrrad +Girokonto:Lebenshaltung:Gesundheit +Girokonto:Lebenshaltung:Haushalt sonst. +Girokonto:Lebenshaltung:Heimwerken +Girokonto:Lebenshaltung:Kontoführung +Girokonto:Lebenshaltung:Körperpflege +Girokonto:Lebenshaltung:Lebensmittel +Girokonto:Lebenshaltung:Telekommunikation +Girokonto:Lebenshaltung:Versicherungen +Girokonto:Privat +Girokonto:Privat:Geschenke +Girokonto:Privat:Mitgliedsbeiträge +Girokonto:Privat:Steuerausgleich Ehetine +Girokonto:Sonstiges +Girokonto:Sparen +Girokonto:Sparen:Depot +Girokonto:Sparen:Tagesgeld +Girokonto:unbestimmte Verwendung:Bargeld +Girokonto:unbestimmte Verwendung:Kreditkarte +Haushaltskonto:Beiträge +Umbuchung diff --git a/scripts/initial_load/categories/extract_categories.py b/scripts/initial_load/categories/extract_categories.py new file mode 100644 index 0000000..59389dc --- /dev/null +++ b/scripts/initial_load/categories/extract_categories.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Extract unique categories from b4w CSV export.""" + +import argparse +import csv +from pathlib import Path + + +def extract_unique_categories(csv_path: str, output_path: str) -> list[str]: + """Merge CSV categories with the existing output file, sorted, one per line.""" + + unique_categories: set[str] = set() + + with open(csv_path, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + category = (row.get("Category") or "").strip() + if category: + unique_categories.add(category) + + output_file_path = Path(output_path) + if output_file_path.exists(): + with open(output_file_path, "r", encoding="utf-8") as f: + for line in f: + if category := line.strip(): + unique_categories.add(category) + + sorted_categories = sorted(unique_categories) + + with open(output_file_path, "w", encoding="utf-8") as f: + f.writelines(f"{cat}\n" for cat in sorted_categories) + + print(f"Extracted {len(sorted_categories)} unique categories") + print(f"Output written to: {output_path}") + return sorted_categories + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Extract unique categories from a b4w CSV export." + ) + parser.add_argument("--csv", required=True, help="Pfad der b4w-Exportdatei (CSV)") + parser.add_argument( + "--output", + default="scripts/initial_load/categories/categories.txt", + help="Pfad der Ausgabedatei (Default: %(default)s)", + ) + args = parser.parse_args() + + extract_unique_categories(args.csv, args.output) + + +if __name__ == "__main__": + main() diff --git a/tests/test_extract_categories.py b/tests/test_extract_categories.py new file mode 100644 index 0000000..3397bb3 --- /dev/null +++ b/tests/test_extract_categories.py @@ -0,0 +1,125 @@ +"""Tests for extract_categories.py - extracting unique categories from b4w CSV exports.""" + +import csv +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from scripts.initial_load.categories.extract_categories import extract_unique_categories + + +def write_csv(path: Path, categories: list[str]) -> None: + with open(path, "w", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=["Id", "AcctId", "Category"]) + writer.writeheader() + for i, category in enumerate(categories, 1): + writer.writerow({"Id": str(i), "AcctId": "587713594", "Category": category}) + + +def read_lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").splitlines() + + +def test_extract_unique_categories_basic(tmp_path): + csv_file = tmp_path / "input.csv" + output_file = tmp_path / "categories.txt" + write_csv(csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]) + + result = extract_unique_categories(str(csv_file), str(output_file)) + + assert result == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"] + assert read_lines(output_file) == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"] + + +def test_extract_unique_categories_no_duplicates(tmp_path): + csv_file = tmp_path / "input.csv" + output_file = tmp_path / "categories.txt" + write_csv( + csv_file, + ["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Lebenshaltung"], + ) + + extract_unique_categories(str(csv_file), str(output_file)) + + assert len(read_lines(output_file)) == 2 + + +def test_extract_unique_categories_output_format(tmp_path): + csv_file = tmp_path / "input.csv" + output_file = tmp_path / "categories.txt" + write_csv( + csv_file, + ["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Privat"], + ) + + extract_unique_categories(str(csv_file), str(output_file)) + + lines = read_lines(output_file) + assert len(lines) == 3 + # Plain list: no numbering prefix, no blank lines. + assert all(line == line.strip() and not line[0].isdigit() for line in lines) + + +def test_extract_unique_categories_german_special_chars(tmp_path): + csv_file = tmp_path / "input.csv" + output_file = tmp_path / "categories.txt" + write_csv( + csv_file, + ["Girokonto:Freizeit:Urlaub&Ausflüge", "Girokonto:Privat:Mitgliedsbeiträge"], + ) + + extract_unique_categories(str(csv_file), str(output_file)) + + assert read_lines(output_file) == [ + "Girokonto:Freizeit:Urlaub&Ausflüge", + "Girokonto:Privat:Mitgliedsbeiträge", + ] + + +def test_extract_unique_categories_handles_empty_csv(tmp_path): + csv_file = tmp_path / "input.csv" + output_file = tmp_path / "categories.txt" + csv_file.write_text("Id,AcctId,Category\n", encoding="utf-8") + + extract_unique_categories(str(csv_file), str(output_file)) + + assert output_file.exists() + assert read_lines(output_file) == [] + + +def test_extract_unique_categories_handles_csv_with_all_empty_categories(tmp_path): + csv_file = tmp_path / "input.csv" + output_file = tmp_path / "categories.txt" + csv_file.write_text( + "Id,AcctId,Category\n1,587713594,\n2,587713594,\n3,587713594,\n", + encoding="utf-8", + ) + + extract_unique_categories(str(csv_file), str(output_file)) + + assert read_lines(output_file) == [] + + +def test_extract_unique_categories_rerun_is_idempotent(tmp_path): + csv_file = tmp_path / "input.csv" + output_file = tmp_path / "categories.txt" + write_csv(csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]) + + extract_unique_categories(str(csv_file), str(output_file)) + first_run = read_lines(output_file) + + # A new category appears in the CSV on the second run. + write_csv( + csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Privat"] + ) + extract_unique_categories(str(csv_file), str(output_file)) + second_run = read_lines(output_file) + + # Existing entries are merged, not duplicated; list stays sorted and plain. + assert second_run == [ + "Girokonto:Lebenshaltung", + "Girokonto:Privat", + "Girokonto:Sparen", + ] + assert first_run == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]