from __future__ import annotations import csv import io import random import re from functools import lru_cache import gradio as gr import requests # ------------------------------------------------------------ # ISIN core # ------------------------------------------------------------ _ISIN_BODY_RE = re.compile(r"^[A-Z0-9]{11}$") _ISIN_FULL_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$") def normalize_isin(s: str) -> str: return re.sub(r"[\s-]+", "", (s or "")).upper() def expand_isin_body(body: str) -> str: """ Expand an 11-char ISIN body to digits: A -> 10, ..., Z -> 35, digits stay unchanged. """ body = normalize_isin(body) if not _ISIN_BODY_RE.fullmatch(body): raise ValueError( "ISIN-Body muss aus genau 11 alphanumerischen Zeichen bestehen." ) out: list[str] = [] for ch in body: if ch.isdigit(): out.append(ch) else: out.append(str(ord(ch) - ord("A") + 10)) return "".join(out) def luhn_mod10_check_digit_from_expanded(expanded_digits: str) -> str: """ Main implementation: traverse from right to left, double every second digit. """ if not expanded_digits.isdigit(): raise ValueError("expanded_digits muss nur aus Ziffern bestehen.") total = 0 double = True # because the check digit will be appended on the right for ch in reversed(expanded_digits): n = int(ch) if double: n *= 2 if n > 9: n -= 9 total += n double = not double return str((10 - (total % 10)) % 10) def compute_isin_check_digit(body: str) -> str: expanded = expand_isin_body(body) return luhn_mod10_check_digit_from_expanded(expanded) def complete_isin(body: str) -> str: body = normalize_isin(body) return body + compute_isin_check_digit(body) def validate_isin(isin: str) -> bool: isin = normalize_isin(isin) if not _ISIN_FULL_RE.fullmatch(isin): return False return compute_isin_check_digit(isin[:-1]) == isin[-1] # ------------------------------------------------------------ # Independent reference implementation # (different formulation, same mathematical result) # ------------------------------------------------------------ def compute_isin_check_digit_reference(body: str) -> str: """ Reference implementation of the same rule, but written independently: - expand letters - reason from LEFT using parity of the final full length """ digits = [int(ch) for ch in expand_isin_body(body)] # final full number length = len(digits) + 1 (for the check digit) full_len = len(digits) + 1 parity = full_len % 2 total = 0 for i, n in enumerate(digits): if i % 2 == parity: n *= 2 if n > 9: n -= 9 total += n return str((10 - (total % 10)) % 10) def validate_isin_reference(isin: str) -> bool: isin = normalize_isin(isin) if not _ISIN_FULL_RE.fullmatch(isin): return False return compute_isin_check_digit_reference(isin[:-1]) == isin[-1] # ------------------------------------------------------------ # Manual UI helpers # ------------------------------------------------------------ def run_manual_isin_tool(user_input: str) -> str: s = normalize_isin(user_input) if _ISIN_BODY_RE.fullmatch(s): full = complete_isin(s) ref = compute_isin_check_digit_reference(s) return ( f"Eingabe erkannt als 11-stelliger ISIN-Body.\n\n" f"Vollständige ISIN: {full}\n" f"Prüfziffer: {full[-1]}\n" f"Referenz-Prüfziffer: {ref}\n" f"Konsistenz: {'OK' if ref == full[-1] else 'ABWEICHUNG'}" ) if _ISIN_FULL_RE.fullmatch(s): ok_main = validate_isin(s) ok_ref = validate_isin_reference(s) return ( f"Eingabe erkannt als vollständige 12-stellige ISIN.\n\n" f"ISIN: {s}\n" f"Validierung (Hauptimplementierung): {'gültig' if ok_main else 'ungültig'}\n" f"Validierung (Referenzimplementierung): {'gültig' if ok_ref else 'ungültig'}\n" f"Konsistenz: {'OK' if ok_main == ok_ref else 'ABWEICHUNG'}" ) return ( "Ungültige Eingabe.\n\n" "Erwartet wird entweder:\n" "- ein 11-stelliger ISIN-Body (zur Berechnung), oder\n" "- eine 12-stellige vollständige ISIN (zur Validierung).\n\n" "Erlaubt sind nur A-Z und 0-9; Leerzeichen und Bindestriche werden ignoriert." ) # ------------------------------------------------------------ # Online dataset self-test # ------------------------------------------------------------ REMOTE_SOURCES = { "NSDL mirror (India ISIN dataset on GitHub)": { "url": "https://raw.githubusercontent.com/captn3m0/india-isin-data/main/ISIN.csv", "description": ( "Öffentliches CSV-Repo mit realen ISINs; gut für externe Regressionstests." ), } } def _extract_isins_from_csv_text(text: str) -> list[str]: """ Parse robustly: 1) try DictReader and a column named 'isin' 2) fallback to regex extraction from whole text """ out: list[str] = [] try: reader = csv.DictReader(io.StringIO(text)) if reader.fieldnames: field_map = {f.strip().lower(): f for f in reader.fieldnames if f} isin_field = field_map.get("isin") if isin_field: for row in reader: candidate = normalize_isin(row.get(isin_field, "")) if _ISIN_FULL_RE.fullmatch(candidate): out.append(candidate) except csv.Error: pass if not out: for candidate in re.findall(r"\b[A-Z]{2}[A-Z0-9]{9}[0-9]\b", text.upper()): if _ISIN_FULL_RE.fullmatch(candidate): out.append(candidate) # de-duplicate but preserve deterministic order seen = set() deduped = [] for x in out: if x not in seen: seen.add(x) deduped.append(x) return deduped @lru_cache(maxsize=4) def fetch_remote_isins(source_name: str) -> tuple[str, ...]: source = REMOTE_SOURCES[source_name] resp = requests.get( source["url"], timeout=30, headers={"User-Agent": "ISIN-Validator-Demo/1.0"}, ) resp.raise_for_status() isins = _extract_isins_from_csv_text(resp.text) if not isins: raise RuntimeError("Keine gültigen ISINs im Online-Datensatz gefunden.") return tuple(isins) def _parse_seed(seed_text: str | None) -> int | None: s = (seed_text or "").strip() if not s: return None return int(s) def _render_result_table(rows: list[dict[str, str]], max_rows: int = 1000) -> str: head = "| # | modus | ISIN | result_main | result_ref | ok |\n|---:|---|---|---|---|---|\n" body_lines = [] for row in rows[:max_rows]: body_lines.append( f"| {row['idx']} | {row['mode']} | `{row['isin']}` | " f"`{row['result_main']}` | `{row['result_ref']}` | {row['ok']} |" ) tail = "" if len(rows) > max_rows: tail = f"\n\n… weitere {len(rows) - max_rows} Zeilen ausgeblendet." return head + "\n".join(body_lines) + tail def run_online_self_test( sample_count: int, mode: str, seed_text: str, source_name: str, ) -> str: try: seed = _parse_seed(seed_text) rng = random.Random(seed) dataset = list(fetch_remote_isins(source_name)) if not dataset: return "Fehler: Online-Datensatz leer." n = max(1, min(int(sample_count), len(dataset))) sample = rng.sample(dataset, k=n) rows: list[dict[str, str]] = [] failures: list[dict[str, str]] = [] for idx, isin in enumerate(sample, start=1): op = mode if mode == "mixed": op = rng.choice(["compute", "verify"]) if op == "verify": main = str(validate_isin(isin)) ref = str(validate_isin_reference(isin)) ok = (main == "True") and (ref == "True") else: # compute main = complete_isin(isin[:-1]) ref = isin[:-1] + compute_isin_check_digit_reference(isin[:-1]) ok = (main == isin) and (ref == isin) row = { "idx": str(idx), "mode": op, "isin": isin, "result_main": main, "result_ref": ref, "ok": "✅" if ok else "❌", } rows.append(row) if not ok: failures.append(row) passed = len(rows) - len(failures) rate = 100.0 * passed / len(rows) summary = ( f"## Online-Selbsttest\n\n" f"- Quelle: **{source_name}**\n" f"- Geladene Datensätze: **{len(dataset)}**\n" f"- Stichprobe: **{len(rows)}**\n" f"- Modus: **{mode}**\n" f"- Seed: **{seed if seed is not None else 'zufällig'}**\n" f"- Bestanden: **{passed}/{len(rows)} ({rate:.1f}%)**\n" ) note = ( "\n**Hinweis:** Dieser Test vergleicht gegen reale Online-ISINs " "und nutzt zusätzlich eine unabhängig formulierte Referenzimplementierung. " "Das ist ein starker externer Check, aber kein mathematischer Universalbeweis.\n" ) if failures: fail_block = "\n### Fehlgeschlagene Fälle\n\n" + _render_result_table( failures, max_rows=1000 ) else: fail_block = "\n### Erste Stichproben\n\n" + _render_result_table( rows, max_rows=1000 ) return summary + note + fail_block except requests.RequestException as e: return f"Netzwerkfehler beim Laden des Online-Datensatzes:\n\n`{type(e).__name__}: {e}`" except Exception as e: return f"Fehler im Selbsttest:\n\n`{type(e).__name__}: {e}`" # ------------------------------------------------------------ # Gradio UI # ------------------------------------------------------------ with gr.Blocks() as demo: gr.Markdown( """ # ISIN Rechner / Validator Diese App kann: 1. eine ISIN-Prüfziffer berechnen, 2. eine vollständige ISIN validieren, 3. einen Online-Selbsttest gegen reale veröffentlichte ISINs ausführen. """ ) with gr.Tab("Manuell"): with gr.Row(): manual_input = gr.Textbox( label="ISIN oder ISIN-Body", placeholder="z.B. US037833100 oder US0378331005", lines=1, ) manual_button = gr.Button("Berechnen / Validieren") manual_output = gr.Textbox(label="Ergebnis", lines=10) manual_button.click( fn=run_manual_isin_tool, inputs=manual_input, outputs=manual_output, ) with gr.Tab("Online-Selbsttest"): gr.Markdown( """ Der Selbsttest lädt zufällige echte ISINs aus einem öffentlichen Online-Datensatz, wählt eine Stichprobe und prüft dann `compute`, `verify` oder beides gemischt. """ ) with gr.Row(): source_name = gr.Dropdown( choices=list(REMOTE_SOURCES.keys()), value=list(REMOTE_SOURCES.keys())[0], label="Datenquelle", ) mode = gr.Dropdown( choices=["compute", "verify", "mixed"], value="mixed", label="Testmodus", ) with gr.Row(): sample_count = gr.Slider( minimum=1, maximum=1000, value=25, step=1, label="Anzahl zufälliger ISINs", ) seed_text = gr.Textbox( label="Seed (optional)", placeholder="z.B. 42", lines=1, ) online_button = gr.Button("Online-Selbsttest starten") online_output = gr.Markdown() online_button.click( fn=run_online_self_test, inputs=[sample_count, mode, seed_text, source_name], outputs=online_output, ) demo.launch()