diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 00000000..03bd12ae --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,7 @@ +[bumpversion] +current_version = 0.1.0 +commit = True +tag = True + +[bumpversion:file:cl_sii/__init__.py] + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b5e63dd1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,40 @@ +# editorconfig.org + +root = true + +[*] +max_line_length = 100 +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{ini,py,rst}] +indent_style = space +indent_size = 4 + +[*.py] +multi_line_output = 3 + +[*.{css,html,js,json,scss,xml,yml,yaml}] +indent_style = space +indent_size = 2 + +# minified JavaScript files should not be modified +[**.min.js] +indent_style = ignore +insert_final_newline = ignore + +[*.md] +trim_trailing_whitespace = false + +[*.{diff,patch}] +trim_trailing_whitespace = false + +[*.sh] +indent_style = tab + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6d44c92d --- /dev/null +++ b/.gitignore @@ -0,0 +1,273 @@ +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +coverage +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ +# nyc test coverage +.nyc_output +# custom directories +test-reports + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +staticfiles/ + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# SQLite DBs +*.db +*.sqlite3 + +# Sphinx documentation +docs/_build/ + +# mkdocs documentation +/site + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ + + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + + +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json + + +### Misc editors, IDES + +# JetBrain editors +.idea + +*~ +*.swp +*.swo + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + + +### Git ### +*.orig + + +### Linux template +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + + +### Windows template +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + + +### macOS template +# General +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### VisualStudioCode template +.vscode + + +### SublimeText template +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +*.sublime-project + +# SFTP configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + + +### Vim template +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] + +# Session +Session.vim + +# Temporary +.netrwhist + +# Auto-generated tag files +tags diff --git a/HISTORY.rst b/HISTORY.rst new file mode 100644 index 00000000..5943cfc4 --- /dev/null +++ b/HISTORY.rst @@ -0,0 +1,13 @@ +.. :changelog: + +History +------- + +unreleased (YYYY-MM-DD) ++++++++++++++++++++++++ + +0.1.0 (2019-04-04) ++++++++++++++++++++++++ + +* (PR #2, 2019-04-04) Add class and constants for RUT +* (PR #1, 2019-04-04) Whole setup for a Python package/library diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a148b844 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019, Fyndata (Fynpal SpA) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..81f56241 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include HISTORY.rst +include LICENSE +include README.rst +recursive-include cl_sii *py +include cl_sii/py.typed diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0bfa3b51 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +SHELL = /usr/bin/env bash + +.DEFAULT_GOAL := help +.PHONY: help +.PHONY: clean clean-build clean-pyc clean-test +.PHONY: lint test test-all test-coverage test-coverage-report-console test-coverage-report-html +.PHONY: dist upload-release + +help: + @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}' + +clean: clean-build clean-pyc clean-test ## remove all build, test, lint, coverage and Python artifacts + +clean-build: ## remove build artifacts + rm -rf .eggs/ + rm -rf build/ + rm -rf dist/ + find . -name '*.egg-info' -exec rm -rf {} + + find . -name '*.egg' -exec rm -f {} + + +clean-pyc: ## remove Python file artifacts + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -rf {} + + +clean-test: ## remove test, lint and coverage artifacts + rm -rf .cache/ + rm -rf .tox/ + rm -f .coverage + rm -rf htmlcov/ + rm -rf test-reports/ + rm -rf .mypy_cache/ + +lint: ## run tools for code style analysis, static type check, etc + flake8 --config=setup.cfg cl_sii tests + mypy --config-file setup.cfg cl_sii + +test: ## run tests quickly with the default Python + python setup.py test + +test-all: ## run tests on every Python version with tox + tox + +test-coverage: ## run tests and record test coverage + coverage run --rcfile=setup.cfg setup.py test + +test-coverage-report-console: ## print test coverage summary + coverage report --rcfile=setup.cfg -m + +test-coverage-report-html: ## generate test coverage HTML report + coverage html --rcfile=setup.cfg + +dist: clean ## builds source and wheel package + python setup.py sdist + python setup.py bdist_wheel + twine check dist/* + ls -l dist + +upload-release: ## upload dist packages + python -m twine upload 'dist/*' diff --git a/README.rst b/README.rst index 873c0f2b..654a6cd0 100644 --- a/README.rst +++ b/README.rst @@ -3,3 +3,17 @@ cl-sii Python lib ================= Python library for Servicio de Impuestos Internos (SII) of Chile. + +Status +------------- + +.. image:: https://circleci.com/gh/fyndata/lib-cl-sii-python/tree/develop.svg?style=shield + :target: https://circleci.com/gh/fyndata/lib-cl-sii-python/tree/develop + :alt: CI status + + +Supported Python versions +------------------------- + +Only Python 3.7. Python 3.6 and below will not work because we use some features introduced in +Python 3.7. diff --git a/cl_sii/__init__.py b/cl_sii/__init__.py new file mode 100644 index 00000000..42a55c1c --- /dev/null +++ b/cl_sii/__init__.py @@ -0,0 +1,8 @@ +""" +cl-sii Python lib +================= + +""" + + +__version__ = '0.1.0' diff --git a/cl_sii/py.typed b/cl_sii/py.typed new file mode 100644 index 00000000..e69de29b 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/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..be8f58d7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# This file exists because many tools, services and platforms expect it to be +# in the root directory of a Python project. +-r requirements/base.txt diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 00000000..1601bfc4 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,8 @@ +# requirements common to all "run modes" +# note: it is mandatory to register all dependencies of the required packages. + +# Required packages: +#none + +# Packages dependencies: +#none diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 00000000..a9df4bed --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,8 @@ +# note: it is mandatory to register all dependencies of the required packages. +-r base.txt + +# Required packages: +#none + +# Packages dependencies: +#none diff --git a/requirements/extras.txt b/requirements/extras.txt new file mode 100644 index 00000000..7cb924e2 --- /dev/null +++ b/requirements/extras.txt @@ -0,0 +1,4 @@ +# note: it is NOT mandatory to register all dependencies of the required packages. + +# Required packages: +#none diff --git a/requirements/release.txt b/requirements/release.txt new file mode 100644 index 00000000..49ca0a96 --- /dev/null +++ b/requirements/release.txt @@ -0,0 +1,23 @@ +# note: it is mandatory to register all dependencies of the required packages. +# (pro tip: keep to the minimum the number of packages declared here) +-r base.txt + +# Required packages: +bumpversion==0.5.3 +setuptools==40.8.0 +twine==1.13.0 +wheel==0.33.1 + +# Packages dependencies: +# - twine: +# - pkginfo +# - readme-renderer +# - requests +# - requests-toolbelt +# - setuptools +# - tqdm +pkginfo==1.5.0.1 +readme-renderer==24.0 +requests==2.21.0 +requests-toolbelt==0.9.1 +tqdm==4.31.1 diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 00000000..5fb90db5 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,37 @@ +# note: it is mandatory to register all dependencies of the required packages. +-r base.txt + +# Required packages: +codecov==2.0.15 +coverage==4.5.2 +flake8==3.7.6 +mypy==0.670 +tox==3.7.0 + +# Packages dependencies: +# - codecov: +# - coverage +# - requests +# - flake8: +# - mccabe +# - pycodestyle +# - pyflakes +# - mypy: +# - mypy-extensions +# - typed-ast +# - tox: +# - filelock +# - pluggy +# - py +# - toml +# - virtualenv +filelock==3.0.10 +mccabe==0.6.1 +mypy-extensions==0.4.1 +pluggy==0.9.0 +py==1.8.0 +pycodestyle==2.5.0 +pyflakes==2.1.0 +toml==0.10.0 +typed-ast==1.3.1 +virtualenv==16.4.3 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..ddb01ab7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,49 @@ +[bdist_wheel] +universal = 0 + +[coverage:run] +source = cl_sii/ +omit = + tests/* +branch = True + +[coverage:report] +exclude_lines = + pragma: no cover + if __name__ == .__main__. +show_missing = True + +[coverage:html] +directory = test-reports/coverage/html + +[mypy] +python_version = 3.7 +platform = linux + +follow_imports = normal +ignore_missing_imports = False +strict_optional = True +disallow_untyped_defs = True +check_untyped_defs = True +warn_return_any = True + +[flake8] +ignore = + # W503 line break before binary operator + W503 + +exclude = + *.egg-info/, + .git/, + .mypy_cache/, + .pyenvs/, + __pycache__/, + build/, + dist/, + docs/ + +max-line-length = 100 + +doctests = True +show-source = True +statistics = True diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..95b4c42a --- /dev/null +++ b/setup.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +import os +import re +from typing import Sequence + +from setuptools import find_packages, setup + + +def get_version(*file_paths: Sequence[str]) -> str: + filename = os.path.join(os.path.dirname(__file__), *file_paths) + version_file = open(filename).read() + version_match = re.search( + r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError('Unable to find version string.') + + +version = get_version('cl_sii', '__init__.py') + +readme = open('README.rst').read() +history = open('HISTORY.rst').read().replace('.. :changelog:', '') + +requirements = [ +] + +extras_requirements = { +} + +setup_requirements = [ +] + +test_requirements = [ + # note: include here only packages **imported** in test code (e.g. 'requests-mock'), NOT those + # like 'coverage' or 'tox'. +] + +# note: the "typing information" of this project's packages is not made available to its users +# automatically; it needs to be packaged and distributed. The way to do so is fairly new and +# it is specified in PEP 561 - "Distributing and Packaging Type Information". +# See: +# - https://www.python.org/dev/peps/pep-0561/#packaging-type-information +# - https://github.com/python/typing/issues/84 +# - https://github.com/python/mypy/issues/3930 +# warning: remember to replicate this in the manifest file for source distribution ('MANIFEST.in'). +_package_data = { + 'cl_sii': [ + # Indicates that the "typing information" of the package should be distributed. + 'py.typed', + ], +} + +setup( + author='Fyndata (Fynpal SpA)', + author_email='no-reply@fyndata.com', + classifiers=[ + # See https://pypi.org/classifiers/ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.7', + ], + description="""Python library for Servicio de Impuestos Internos (SII) of Chile.""", + extras_require=extras_requirements, + install_requires=requirements, + license="MIT", + long_description=readme + '\n\n' + history, + long_description_content_type='text/x-rst', # for Markdown: 'text/markdown' + include_package_data=True, + name='cl-sii', + package_data=_package_data, + packages=find_packages(exclude=['docs', 'tests*']), + python_requires='>=3.7, <3.8', + setup_requires=setup_requirements, + test_suite='tests', + tests_require=test_requirements, + url='https://github.com/fyndata/lib-cl-sii-python', + version=version, + zip_safe=False, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..f6e6abb1 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +[tox] +envlist = + py37 + +[testenv] +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/cl_sii +commands = coverage run --rcfile=setup.cfg setup.py test +deps = + -r{toxinidir}/requirements/test.txt + -r{toxinidir}/requirements/extras.txt +basepython = + py37: python3.7