From 07aef2feafac20fe43e493659a1144e1d88c897b Mon Sep 17 00:00:00 2001 From: Amir Douzandeh Date: Fri, 9 Oct 2026 13:45:45 +0200 Subject: [PATCH] Add Iranian national ID number validation The Iranian national ID number (Code Melli) is a 10-digit number with a trailing modulo 11 check digit. Numbers of 8 or 9 digits are padded with leading zeros, and numbers consisting of a single repeated digit are rejected. --- docs/index.rst | 1 + docs/stdnum.ir.nid.rst | 5 ++ stdnum/ir/__init__.py | 21 ++++++++ stdnum/ir/nid.py | 96 +++++++++++++++++++++++++++++++++ tests/test_ir_nid.doctest | 108 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 docs/stdnum.ir.nid.rst create mode 100644 stdnum/ir/__init__.py create mode 100644 stdnum/ir/nid.py create mode 100644 tests/test_ir_nid.doctest diff --git a/docs/index.rst b/docs/index.rst index 7cb0b702..0f8d231a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -244,6 +244,7 @@ Available formats in_.gstin in_.pan in_.vid + ir.nid is_.kennitala is_.vsk isan diff --git a/docs/stdnum.ir.nid.rst b/docs/stdnum.ir.nid.rst new file mode 100644 index 00000000..aa1a2177 --- /dev/null +++ b/docs/stdnum.ir.nid.rst @@ -0,0 +1,5 @@ +stdnum.ir.nid +============= + +.. automodule:: stdnum.ir.nid + :members: \ No newline at end of file diff --git a/stdnum/ir/__init__.py b/stdnum/ir/__init__.py new file mode 100644 index 00000000..39cc587a --- /dev/null +++ b/stdnum/ir/__init__.py @@ -0,0 +1,21 @@ +# __init__.py - collection of Iranian numbers +# coding: utf-8 +# +# Copyright (C) 2026 Amir Douzandeh +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see . + +"""Collection of Iranian numbers.""" + +from __future__ import annotations diff --git a/stdnum/ir/nid.py b/stdnum/ir/nid.py new file mode 100644 index 00000000..dc86ffce --- /dev/null +++ b/stdnum/ir/nid.py @@ -0,0 +1,96 @@ +# nid.py - functions for handling Iranian national identification numbers +# coding: utf-8 +# +# Copyright (C) 2026 Amir Douzandeh +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see . + +"""National ID Number (Iranian national identification number). + +The Iranian National ID Number (Code Melli) is a 10-digit number assigned to +every Iranian citizen. The last digit is a check digit. Leading zeros are +significant but are often lost when the number is stored as an integer, so +numbers of 8 or 9 digits are padded with zeros. + +More information: + +* https://en.wikipedia.org/wiki/National_identification_number#Iran + +>>> validate('0499370899') +'0499370899' +>>> validate('84575948') # leading zeros are restored +'0084575948' +>>> validate('0499370898') +Traceback (most recent call last): + ... +InvalidChecksum: ... +>>> validate('04993708') +Traceback (most recent call last): + ... +InvalidChecksum: ... +>>> validate('049937') +Traceback (most recent call last): + ... +InvalidLength: ... +>>> validate('1111111111') # repeated digits are never valid +Traceback (most recent call last): + ... +InvalidFormat: ... +""" + +from __future__ import annotations + +from stdnum.exceptions import * +from stdnum.util import clean, isdigits + + +def compact(number: str) -> str: + """Convert the number to the minimal representation. This strips the + number of any valid separators, removes surrounding whitespace and pads + numbers of 8 or 9 digits with leading zeros to 10 digits.""" + number = clean(number, ' -').strip() + if isdigits(number) and 8 <= len(number) < 10: + number = number.zfill(10) + return number + + +def calc_check_digits(number: str) -> str: + """Calculate the check digit for the specified number. The number + passed should not have the check digit included.""" + remainder = sum( + int(n) * w for n, w in zip(number[:9], range(10, 1, -1))) % 11 + return str(remainder if remainder < 2 else 11 - remainder) + + +def validate(number: str) -> str: + """Check if the number is a valid Iranian national ID number. This + checks the length, formatting and check digit.""" + number = compact(number) + if not isdigits(number): + raise InvalidFormat() + if len(number) != 10: + raise InvalidLength() + if len(set(number)) == 1: + raise InvalidFormat() + if calc_check_digits(number) != number[-1]: + raise InvalidChecksum() + return number + + +def is_valid(number: str) -> bool: + """Check if the number is a valid Iranian national ID number.""" + try: + return bool(validate(number)) + except ValidationError: + return False diff --git a/tests/test_ir_nid.doctest b/tests/test_ir_nid.doctest new file mode 100644 index 00000000..88f3d142 --- /dev/null +++ b/tests/test_ir_nid.doctest @@ -0,0 +1,108 @@ +test_ir_nid.doctest - more detailed doctests for stdnum.ir.nid module + +Copyright (C) 2026 Amir Douzandeh + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, see . + + +This file contains more detailed doctests for the stdnum.ir.nid module. + +>>> from stdnum.ir import nid +>>> from stdnum.exceptions import * + + +Numbers that are published as examples of valid national ID numbers. + +>>> numbers = ''' +... +... 0499370899 +... 0084575948 +... 0067749828 +... 0012345679 +... +... ''' +>>> [x for x in numbers.splitlines() if x and not nid.is_valid(x)] +[] + + +Leading zeros are often lost when the number is stored as an integer, so +shorter numbers are padded. + +>>> nid.compact('84575948') +'0084575948' +>>> nid.validate('84575948') +'0084575948' +>>> nid.validate('12345679') +'0012345679' +>>> nid.validate('1234567') # too short +Traceback (most recent call last): + ... +InvalidLength: ... +>>> nid.validate('04993708990') # too long +Traceback (most recent call last): + ... +InvalidLength: ... + + +Separators and surrounding whitespace are removed. + +>>> nid.validate(' 049-937089-9 ') +'0499370899' + + +Numbers consisting of a single repeated digit are not valid, even though some +of them pass the checksum. + +>>> nid.is_valid('0000000000') +False +>>> nid.is_valid('1111111111') +False +>>> nid.is_valid('2222222222') +False +>>> nid.validate('1111111111') +Traceback (most recent call last): + ... +InvalidFormat: ... + + +Other invalid numbers. + +>>> nid.validate('0499370898') # wrong check digit +Traceback (most recent call last): + ... +InvalidChecksum: ... +>>> nid.validate('04993708A9') # not numeric +Traceback (most recent call last): + ... +InvalidFormat: ... +>>> nid.validate('\u0660\u0664\u0669\u0669\u0663\u0667\u0660\u0668\u0669\u0669') # non-ASCII digits +Traceback (most recent call last): + ... +InvalidFormat: ... +>>> nid.validate('') +Traceback (most recent call last): + ... +InvalidFormat: ... +>>> nid.is_valid('0499370898') +False + + +Check digit calculation. + +>>> nid.calc_check_digits('049937089') +'9' +>>> nid.calc_check_digits('008457594') +'8' +>>> nid.calc_check_digits('000000000') +'0'