diff --git a/cl_sii/rut/__init__.py b/cl_sii/rut/__init__.py new file mode 100644 index 00000000..1cd56f6f --- /dev/null +++ b/cl_sii/rut/__init__.py @@ -0,0 +1,185 @@ +""" +Utilities for dealing with Chile's RUT ("Rol Único Tributario"). + +The terms RUT and RUN ("Rol Único Nacional") may be used interchangeably but +only when the holder is a natural person ("persona natural"); a legal person +("persona jurídica") does not have a RUN. + +RUT "canonical format": no dots ('.'), with dash ('-'), uppercase K e.g. +``'76042235-5'``, ``'96874030-K'``. + +""" +import itertools +import random + +from . import constants + + +class Rut: + + """ + Representation of a RUT. + + It verifies that the input is syntactically valid and, optionally, that the + "digito verificador" is correct. + + It does NOT check that the value is within boundaries deemed acceptable by + the SII (although the regex used does implicitly impose some) nor that the + RUT has actually been assigned to some person or entity. + + >>> Rut('96874030-K') + Rut('96874030-K')> + >>> str(Rut('96874030-K')) + '96874030-K' + >>> Rut('96874030-K').digits + '96874030' + >>> Rut('96874030-K').dv + 'K' + >>> Rut('96874030-K').canonical + '96874030-K' + >>> Rut('96874030-K').verbose + '96.874.030-K' + >>> Rut('96874030-K').digits_with_dots + '96.874.030' + + >>> Rut('77879240-0') == Rut('77.879.240-0') + True + >>> Rut('96874030-K') == Rut('9.68.7403.0-k') + True + + """ + + def __init__(self, value: str, validate_dv: bool = False) -> None: + """ + Constructor. + + :param value: a string that represents a syntactically valid RUT + :param validate_dv: whether to validate that the RUT's + "digito verificador" is correct + + :raises ValueError: + :raises TypeError: + + """ + invalid_rut_msg = "Syntactically invalid RUT." + + if isinstance(value, Rut): + value = value.canonical + if not isinstance(value, str): + raise TypeError("Invalid type.") + + clean_value = Rut.clean_str(value) + match_obj = constants.RUT_CANONICAL_STRICT_REGEX.match(clean_value) + if match_obj is None: + raise ValueError(invalid_rut_msg, value) + + match_groups = match_obj.groupdict() + self._digits = match_groups['digits'] + self._dv = match_groups['dv'] + + if validate_dv: + if Rut.calc_dv(self._digits) != self._dv: + raise ValueError("RUT's \"digito verificador\" is incorrect.", value) + + ############################################################################ + # properties + ############################################################################ + + @property + def canonical(self) -> str: + return f'{self._digits}-{self._dv}' + + @property + def verbose(self) -> str: + return f'{self.digits_with_dots}-{self._dv}' + + @property + def digits(self) -> str: + return self._digits + + @property + def digits_with_dots(self) -> str: + """Return RUT digits with a dot ('.') as thousands separator.""" + # > The ',' option signals the use of a comma for a thousands separator. + # https://docs.python.org/3/library/string.html#format-specification-mini-language + return '{:,}'.format(int(self.digits)).replace(',', '.') + + @property + def dv(self) -> str: + return self._dv + + ############################################################################ + # magic methods + ############################################################################ + + def __str__(self) -> str: + return self.canonical + + def __repr__(self) -> str: + return f"Rut('{self.canonical}')" + + def __eq__(self, other: object) -> bool: + if isinstance(other, Rut): + return self.canonical == other.canonical + return False + + def __hash__(self) -> int: + # Objects are hashable so they can be used in hashable collections. + return hash(self.canonical) + + ############################################################################ + # class methods + ############################################################################ + + @classmethod + def clean_str(cls, value: str) -> str: + # note: unfortunately `value.strip('.')` does not remove all the occurrences of '.' in + # 'value' (only the leading and trailing ones). + return value.strip().replace('.', '').upper() + + @classmethod + def calc_dv(cls, rut_digits: str) -> str: + """ + Calculate the "digito verificador" of a RUT's digits. + + >>> Rut.calc_dv('60910000') + '1' + >>> Rut.calc_dv('76555835') + '2' + >>> Rut.calc_dv('76177907') + '9' + >>> Rut.calc_dv('76369187') + 'K' + >>> Rut.calc_dv('77879240') + '0' + >>> Rut.calc_dv('96874030') + 'K' + + """ + if rut_digits.strip().isdigit() is False: + raise ValueError("Must be a sequence of digits.") + + # Based on: + # https://gist.github.com/rbonvall/464824/4b07668b83ee45121345e4634ebce10dc6412ba3 + s = sum( + d * f + for d, f + in zip(map(int, reversed(rut_digits)), itertools.cycle(range(2, 8))) + ) + result_alg = 11 - (s % 11) + return {10: 'K', 11: '0'}.get(result_alg, str(result_alg)) + + @classmethod + def random(cls) -> 'Rut': + """ + Generate a random RUT. + + Value will be within proper boundaries and "digito verificador" + will be calculated appropriately i.e. it is not random. + + """ + rut_digits = str(random.randint( + constants.RUT_DIGITS_MIN_VALUE, + constants.RUT_DIGITS_MAX_VALUE)) + rut_dv = Rut.calc_dv(rut_digits) + return Rut(f'{rut_digits}-{rut_dv}') diff --git a/cl_sii/rut/constants.py b/cl_sii/rut/constants.py new file mode 100644 index 00000000..edb4f2da --- /dev/null +++ b/cl_sii/rut/constants.py @@ -0,0 +1,20 @@ +""" +RUT-related constants. + +Source: XML type 'RUTType' in official schema 'SiiTypes_v10.xsd'. +https://github.com/fynlabs/lib-cl-sii-python/blob/a80edd9/vendor/cl_sii/ref/factura_electronica/schema_dte/SiiTypes_v10.xsd#L121-L130 + +""" +import re + + +RUT_CANONICAL_STRICT_REGEX = re.compile(r'^(?P\d{1,8})-(?P[\dK])$') +"""RUT (strict) regex for canonical format.""" +RUT_CANONICAL_MAX_LENGTH = 10 +"""RUT max length for canonical format.""" +RUT_CANONICAL_MIN_LENGTH = 3 +"""RUT min length for canonical format.""" +RUT_DIGITS_MAX_VALUE = 99999999 +"""RUT digits max value.""" +RUT_DIGITS_MIN_VALUE = 50000000 +"""RUT digits min value.""" diff --git a/tests/test_rut.py b/tests/test_rut.py new file mode 100644 index 00000000..5d7de911 --- /dev/null +++ b/tests/test_rut.py @@ -0,0 +1,196 @@ +import unittest + +from cl_sii import rut # noqa: F401 +from cl_sii.rut import constants # noqa: F401 + + +class RutTest(unittest.TestCase): + + valid_rut_canonical: str + valid_rut_dv: str + valid_rut_digits: str + valid_rut_digits_with_dots: str + valid_rut_verbose: str + + invalid_rut_canonical: str + invalid_rut_dv: str + + valid_rut_instance: rut.Rut + invalid_rut_instance: rut.Rut + + @classmethod + def setUpClass(cls) -> None: + cls.valid_rut_canonical = '6824160-K' + cls.valid_rut_dv = 'K' + cls.valid_rut_digits = '6824160' + cls.valid_rut_digits_with_dots = '6.824.160' + cls.valid_rut_verbose = '6.824.160-K' + + cls.invalid_rut_canonical = '6824160-0' + cls.invalid_rut_dv = '0' + + cls.valid_rut_instance = rut.Rut(cls.valid_rut_canonical) + cls.invalid_rut_instance = rut.Rut(cls.invalid_rut_canonical) + + ############################################################################ + # instance + ############################################################################ + + def test_fail_type_error(self) -> None: + with self.assertRaises(TypeError): + rut.Rut(object()) + with self.assertRaises(TypeError): + rut.Rut(1) + with self.assertRaises(TypeError): + rut.Rut(None) + + def test_ok_same_type(self) -> None: + self.assertEqual( + rut.Rut(rut.Rut('1-1')), + rut.Rut('1-1')) + + def test_instance_empty_string(self) -> None: + rut_value = '' + with self.assertRaises(ValueError) as context_manager: + rut.Rut(rut_value) + + exception = context_manager.exception + message, value = exception.args + self.assertEqual(message, 'Syntactically invalid RUT.') + self.assertEqual(value, rut_value, 'Different RUT value.') + + def test_instance_invalid_rut_format(self) -> None: + rut_value = 'invalid rut format' + with self.assertRaises(ValueError) as context_manager: + rut.Rut(rut_value) + + exception = context_manager.exception + message, value = exception.args + self.assertEqual(message, 'Syntactically invalid RUT.') + self.assertEqual(value, rut_value, 'Different RUT value.') + + def test_instance_short_rut(self) -> None: + rut_value = '1-0' + rut.Rut(rut_value) + + def test_instance_long_rut(self) -> None: + rut_value = '123456789-0' + with self.assertRaises(ValueError) as context_manager: + rut.Rut(rut_value) + + exception = context_manager.exception + message, value = exception.args + self.assertEqual(message, 'Syntactically invalid RUT.') + self.assertEqual(value, rut_value, 'Different RUT value.') + + def test_instance_validate_dv_ok(self) -> None: + rut.Rut(self.valid_rut_canonical, validate_dv=True) + + def test_instance_validate_dv_in_lowercase(self) -> None: + rut_instance = rut.Rut(self.valid_rut_canonical.lower(), validate_dv=True) + self.assertFalse(rut_instance.dv.isnumeric()) + self.assertEqual(rut_instance.dv, self.valid_rut_dv) + + def test_instance_validate_dv_raise_exception(self) -> None: + with self.assertRaises(ValueError) as context_manager: + rut.Rut(self.invalid_rut_canonical, validate_dv=True) + + exception = context_manager.exception + message, value = exception.args + self.assertEqual(message, "RUT's \"digito verificador\" is incorrect.") + self.assertEqual(value, self.invalid_rut_canonical, 'Different RUT value.') + + ############################################################################ + # properties + ############################################################################ + + def test_canonical(self) -> None: + self.assertEqual(self.valid_rut_instance.dv, self.valid_rut_dv) + + def test_verbose(self) -> None: + self.assertEqual(self.valid_rut_instance.verbose, self.valid_rut_verbose) + + def test_digits(self) -> None: + self.assertEqual(self.valid_rut_instance.digits, self.valid_rut_digits) + + def test_digits_with_dots(self) -> None: + self.assertEqual(self.valid_rut_instance.digits_with_dots, self.valid_rut_digits_with_dots) + + def test_dv(self) -> None: + self.assertEqual(self.valid_rut_instance.dv, self.valid_rut_dv) + + def test_dv_upper(self) -> None: + self.assertTrue(self.valid_rut_instance.dv.isupper()) + + ############################################################################ + # magic methods + ############################################################################ + + def test__str__(self) -> None: + self.assertEqual(self.valid_rut_instance.__str__(), self.valid_rut_canonical) + + def test__repr__(self) -> None: + rut_repr = f"Rut('{self.valid_rut_canonical}')" + self.assertEqual(self.valid_rut_instance.__repr__(), rut_repr) + + def test__eq__true(self) -> None: + rut_instance = rut.Rut(self.valid_rut_canonical) + self.assertTrue(self.valid_rut_instance.__eq__(rut_instance)) + + def test__eq__false(self) -> None: + self.assertFalse(self.valid_rut_instance.__eq__(self.invalid_rut_instance)) + + def test__eq__not_rut_instance(self) -> None: + self.assertFalse(self.valid_rut_instance.__eq__(self.valid_rut_canonical)) + + def test__hash__(self) -> None: + rut_hash = hash(self.valid_rut_instance.canonical) + self.assertEqual(self.valid_rut_instance.__hash__(), rut_hash) + + ############################################################################ + # class methods + ############################################################################ + + def test_clean_str_lowercase(self) -> None: + rut_value = f' {self.valid_rut_verbose.lower()} ' + clean_rut = rut.Rut.clean_str(rut_value) + self.assertEqual(clean_rut, self.valid_rut_canonical) + + def test_clean_type_error(self) -> None: + with self.assertRaises(AttributeError) as context_manager: + rut.Rut.clean_str(1) # type: ignore + + exception = context_manager.exception + self.assertEqual(len(exception.args), 1) + message = exception.args[0] + self.assertEqual(message, "'int' object has no attribute 'strip'") + + def test_calc_dv_ok(self) -> None: + dv = rut.Rut.calc_dv(self.valid_rut_digits) + self.assertEqual(dv, self.valid_rut_dv) + + def test_calc_dv_string_uppercase(self) -> None: + digits = 'A' + with self.assertRaises(ValueError) as context_manager: + rut.Rut.calc_dv(digits) + + self.assertListEqual( + list(context_manager.exception.args), + ["Must be a sequence of digits."] + ) + + def test_calc_dv_string_lowercase(self) -> None: + digits = 'a' + with self.assertRaises(ValueError) as context_manager: + rut.Rut.calc_dv(digits) + + self.assertListEqual( + list(context_manager.exception.args), + ["Must be a sequence of digits."] + ) + + def test_random(self) -> None: + rut_instance = rut.Rut.random() + self.assertIsInstance(rut_instance, rut.Rut) + dv = rut.Rut.calc_dv(rut_instance.digits) + self.assertEqual(rut_instance.dv, dv)