from .validation_vin_types import VinCheck #: Letter values from 49 CFR 565.15, indexed by letter A-Z. I, O and Q are #: never used and hold -1; the others run 1-9 in three passes (A-I, J-R, S-Z) #: with the forbidden letters' slots skipped. _LETTER_VALUES = (1, 2, 3, 4, 5, 6, 7, 8, -1, 1, 2, 3, 4, 5, -1, 7, -1, 9, 2, 3, 4, 5, 6, 7, 8, 9) #: Position weights; position 9 is the check digit itself and weighs nothing. _WEIGHTS = (8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2) def _invalid(reason: str) -> VinCheck: return VinCheck(valid=False, normalised=None, wmi=None, check_digit_matches=None, reason=reason) def validate_vin(value: str) -> VinCheck: """Check a 17-character VIN. The check digit is enforced only for North American VINs (first character 1-5), where the law requires it; elsewhere a mismatch is reported in check_digit_matches but does not make the VIN invalid, because many European and Asian VINs never carried one. Validators answer rather than raise. """ if not isinstance(value, str): return _invalid("empty") # Folded by hand: str.upper() is Unicode-aware and Rust's ASCII fold is not. vin = "".join(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch for ch in value if ch != " ") if not vin: return _invalid("empty") if any(not ("0" <= ch <= "9" or "A" <= ch <= "Z") for ch in vin): return _invalid("bad-character") if len(vin) != 17: return _invalid("bad-length") total = 0 for i, ch in enumerate(vin): if ch <= "9": n = ord(ch) - 48 else: n = _LETTER_VALUES[ord(ch) - 65] # I, O and Q read as 1, 0 and 0, so the standard never issues them. if n < 0: return _invalid("forbidden-letter") total += n * _WEIGHTS[i] remainder = total % 11 expected = "X" if remainder == 10 else str(remainder) matches = vin[8] == expected # WMIs starting 1-5 are North American, where 49 CFR 565 makes the check # digit mandatory; anywhere else it is optional, so only report it. if not matches and "1" <= vin[0] <= "5": return _invalid("bad-check-digit") return VinCheck(valid=True, normalised=vin, wmi=vin[:3], check_digit_matches=matches, reason=None)