From 6576aeee275b59df07b39853aed9646ff08338a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Thu, 4 Apr 2019 17:23:48 -0300 Subject: [PATCH 1/4] add sub-package `rut` Helpers and constants related to "RUT". Test have not been implemented. --- cl_sii/rut/__init__.py | 187 ++++++++++++++++++++++++++++++++++++++++ cl_sii/rut/constants.py | 20 +++++ tests/test_rut.py | 11 +++ 3 files changed, 218 insertions(+) create mode 100644 cl_sii/rut/__init__.py create mode 100644 cl_sii/rut/constants.py create mode 100644 tests/test_rut.py diff --git a/cl_sii/rut/__init__.py b/cl_sii/rut/__init__.py new file mode 100644 index 00000000..d6f1a979 --- /dev/null +++ b/cl_sii/rut/__init__.py @@ -0,0 +1,187 @@ +""" +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 + + """ + invalid_rut_msg = "Syntactically invalid RUT." + + clean_value = Rut.clean_str(value) + try: + match_obj = constants.RUT_CANONICAL_STRICT_REGEX.match(clean_value) + except Exception as exc: + raise ValueError(invalid_rut_msg, value) from exc + if match_obj is None: + raise ValueError(invalid_rut_msg, value) + + try: + match_groups = match_obj.groupdict() + self._digits = match_groups['digits'] + self._dv = match_groups['dv'] + except Exception as exc: + raise ValueError(invalid_rut_msg, value) from exc + + 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 != rut_digits.strip().lower(): + raise ValueError + try: + int(rut_digits) + except TypeError as exc: + raise ValueError from exc + + # 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..df310c99 --- /dev/null +++ b/tests/test_rut.py @@ -0,0 +1,11 @@ +import unittest + +from cl_sii import rut # noqa: F401 +from cl_sii.rut import constants # noqa: F401 + + +class RutTest(unittest.TestCase): + + # TODO: implement! + + pass From f110d815b3274c7408f76082bb57133d698c5374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Boris=20Mu=C3=B1oz?= Date: Fri, 14 Dec 2018 11:08:33 -0300 Subject: [PATCH 2/4] rut: improve `Rut` - Remove unnecessary catches of exceptions. - Simplify `calc_dv`. --- cl_sii/rut/__init__.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/cl_sii/rut/__init__.py b/cl_sii/rut/__init__.py index d6f1a979..a58dc96b 100644 --- a/cl_sii/rut/__init__.py +++ b/cl_sii/rut/__init__.py @@ -61,19 +61,13 @@ def __init__(self, value: str, validate_dv: bool = False) -> None: invalid_rut_msg = "Syntactically invalid RUT." clean_value = Rut.clean_str(value) - try: - match_obj = constants.RUT_CANONICAL_STRICT_REGEX.match(clean_value) - except Exception as exc: - raise ValueError(invalid_rut_msg, value) from exc + match_obj = constants.RUT_CANONICAL_STRICT_REGEX.match(clean_value) if match_obj is None: raise ValueError(invalid_rut_msg, value) - try: - match_groups = match_obj.groupdict() - self._digits = match_groups['digits'] - self._dv = match_groups['dv'] - except Exception as exc: - raise ValueError(invalid_rut_msg, value) from exc + 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: @@ -154,12 +148,8 @@ def calc_dv(cls, rut_digits: str) -> str: 'K' """ - if rut_digits != rut_digits.strip().lower(): - raise ValueError - try: - int(rut_digits) - except TypeError as exc: - raise ValueError from exc + if rut_digits.strip().isdigit() is False: + raise ValueError("Must be a sequence of digits.") # Based on: # https://gist.github.com/rbonvall/464824/4b07668b83ee45121345e4634ebce10dc6412ba3 From 734e71054d5c1be9c233352d743b18d70c37ca74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Boris=20Mu=C3=B1oz?= Date: Fri, 14 Dec 2018 11:05:51 -0300 Subject: [PATCH 3/4] rut: add tests for `Rut` class --- tests/test_rut.py | 176 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 2 deletions(-) diff --git a/tests/test_rut.py b/tests/test_rut.py index df310c99..908b97e1 100644 --- a/tests/test_rut.py +++ b/tests/test_rut.py @@ -6,6 +6,178 @@ class RutTest(unittest.TestCase): - # TODO: implement! + valid_rut_canonical: str + valid_rut_dv: str + valid_rut_digits: str + valid_rut_digits_with_dots: str + valid_rut_verbose: str - pass + 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_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) From 3c49f5cf49c37603f52096b302782ce11c090c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Wed, 16 Jan 2019 19:23:06 -0300 Subject: [PATCH 4/4] rut: improve `Rut` class Constructor raises only `TypeError` and `ValueError` (not `AttributeError` anymore), and now supports its own type. --- cl_sii/rut/__init__.py | 8 ++++++++ tests/test_rut.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/cl_sii/rut/__init__.py b/cl_sii/rut/__init__.py index a58dc96b..1cd56f6f 100644 --- a/cl_sii/rut/__init__.py +++ b/cl_sii/rut/__init__.py @@ -57,9 +57,17 @@ def __init__(self, value: str, validate_dv: bool = False) -> None: :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: diff --git a/tests/test_rut.py b/tests/test_rut.py index 908b97e1..5d7de911 100644 --- a/tests/test_rut.py +++ b/tests/test_rut.py @@ -36,6 +36,19 @@ def setUpClass(cls) -> None: # 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: