diff --git a/stdnum/upi.py b/stdnum/upi.py new file mode 100644 index 00000000..f333a6ff --- /dev/null +++ b/stdnum/upi.py @@ -0,0 +1,84 @@ +# upi.py - functions for handling Unique Product Identifiers (UPIs) +# +# Copyright (C) 2026 Jon Arnfred +# +# 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 . + +"""UPI (ISO 4914 Unique Product Identifier). + +The Unique Product Identifier (UPI) is a 12-character alphanumeric code used +to identify over-the-counter (OTC) derivative products. It consists of the +two-character prefix ``QZ``, nine characters that identify the product, and +one check character. Vowels and the letter Y are not used. The check +character uses the ISO 7064 Mod 31, 30 algorithm. + +More information: + +* https://www.iso.org/standard/80506.html +* https://www.anna-dsb.com/upi-/ +* https://zakon.rada.gov.ua/laws/show/z1549-21 + +>>> validate('QZK12RNSP6P6') +'QZK12RNSP6P6' +>>> validate('QZK12RNSP6P7') +Traceback (most recent call last): + ... +InvalidChecksum: ... +>>> calc_check_digit('QZK12RNSP6P') +'6' +""" + +from __future__ import annotations + +from stdnum.exceptions import * +from stdnum.iso7064 import mod_37_36 +from stdnum.util import clean + + +# The UPI alphabet excludes vowels and the letter Y. Using this alphabet with +# the generic Mod x+1, x implementation gives the ISO 7064 Mod 31, 30 method. +_alphabet = '0123456789BCDFGHJKLMNPQRSTVWXZ' + + +def compact(number: str) -> str: + """Convert the UPI to its minimal representation. This strips spaces and + removes surrounding whitespace.""" + return clean(number, ' ').strip().upper() + + +def calc_check_digit(number: str) -> str: + """Calculate the check character for the number.""" + return mod_37_36.calc_check_digit(compact(number), alphabet=_alphabet) + + +def validate(number: str) -> str: + """Check if the number provided is a valid UPI. This checks the length, + format, prefix and check character.""" + number = compact(number) + if not all(x in _alphabet for x in number): + raise InvalidFormat() + if len(number) != 12: + raise InvalidLength() + if not number.startswith('QZ'): + raise InvalidComponent() + mod_37_36.validate(number, alphabet=_alphabet) + return number + + +def is_valid(number: str) -> bool: + """Check if the number provided is a valid UPI.""" + try: + return bool(validate(number)) + except ValidationError: + return False diff --git a/tests/test_upi.doctest b/tests/test_upi.doctest new file mode 100644 index 00000000..f5edb573 --- /dev/null +++ b/tests/test_upi.doctest @@ -0,0 +1,82 @@ +test_upi.doctest - more detailed doctests for the stdnum.upi module + + +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.upi module. + +>>> from stdnum import upi +>>> from stdnum.exceptions import * + + +Spaces and case are normalised by compact(). + +>>> upi.compact(' qzk1 2rnsp6p6 ') +'QZK12RNSP6P6' +>>> upi.validate('qzk12rnsp6p6') +'QZK12RNSP6P6' + + +The prefix must be QZ and only digits and consonants other than Y are +permitted. + +>>> upi.validate('XZK12RNSP6P6') +Traceback (most recent call last): + ... +InvalidComponent: ... +>>> upi.validate('QZA12RNSP6P6') +Traceback (most recent call last): + ... +InvalidFormat: ... +>>> upi.validate('QZY12RNSP6P6') +Traceback (most recent call last): + ... +InvalidFormat: ... +>>> upi.validate('QZK12RNSP6P') +Traceback (most recent call last): + ... +InvalidLength: ... + + +The final character is an ISO 7064 Mod 31, 30 check character. + +>>> upi.calc_check_digit('QZK12RNSP6P') +'6' +>>> upi.validate('QZK12RNSP6P7') +Traceback (most recent call last): + ... +InvalidChecksum: ... + + +These examples have been published by the Derivatives Service Bureau, CME +Group and in ISO 4914 supporting material. + +* https://www.anna-dsb.com/download/dsb-2770-isin-eq-sw-total-return-swap-basket-template-definition/ +* https://www.anna-dsb.com/download/dsb-2771-upi-eq-sw-total-return-swap-single-name-template-definition/ +* https://www.cmegroup.com/notices/reference-data-api/2024/02/20240222.html +* https://zakon.rada.gov.ua/laws/show/z1549-21 + +>>> numbers = ''' +... +... QZDXL66WTF3C +... QZK12RNSP6P6 +... QZNX2JD91QCG +... QZVLFS6FH9VZ +... +... ''' +>>> [x for x in numbers.splitlines() if x and not upi.is_valid(x)] +[] +>>> upi.is_valid('QZK12RNSP6P7') +False