diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bace96..2f972dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/Makefile b/Makefile index d5f48fa..4b16c2f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := bash PATH := ./venv/bin:${PATH} -PYTHON = python3.7 +PYTHON = python3.8 PROJECT = clabe isort = isort $(PROJECT) tests setup.py black = black -S -l 79 --target-version py38 $(PROJECT) tests setup.py @@ -50,5 +50,7 @@ release: test clean python setup.py sdist bdist_wheel twine upload dist/* +compare-banks: + PYTHONPATH=. python scripts/compare_banks.py -.PHONY: all install-test test format lint clean release +.PHONY: all install-test test format lint clean release compare-banks diff --git a/README.md b/README.md index a00bd5e..881272b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ https://es.wikipedia.org/wiki/CLABE ## Requerimientos -Python 3.6 o superior. +Python 3.8 o superior. ## Instalación @@ -30,48 +30,119 @@ $ make test ## Uso básico -Obtener el dígito de control de un número CLABE +### Como tipo personalizado en un modelo de Pydantic + +```python +from pydantic import BaseModel, ValidationError + +from clabe import Clabe + + +class Account(BaseModel): + id: str + clabe: Clabe + + +account = Account(id='123', clabe='723010123456789019') +print(account) +""" +id='123' clabe='723010123456789019' +""" + +try: + account = Account(id='321', clabe='000000000000000011') +except ValidationError as exc: + print(exc) +""" +1 validation error for Account +clabe + código de banco no es válido [type=clabe.bank_code, input_value='000000000000000011', input_type=str] +""" +``` + +### Obtener el dígito de control de un número CLABE ```python import clabe clabe.compute_control_digit('00200000000000000') ``` -Para validar si un número CLABE es válido +### Para validar si un número CLABE es válido ```python import clabe clabe.validate_clabe('002000000000000008') ``` -Para obtener el banco a partir de 3 dígitos +### Para obtener el banco a partir de 3 dígitos ```python import clabe clabe.get_bank_name('002') ``` -Para generar nuevo válido CLABES +### Para generar nuevo válido CLABES ```python import clabe clabe.generate_new_clabes(10, '002123456') ``` -## Para agregar un nuevo banco +## Agregar un nuevo banco + +A partir de la versión **2.0.0**, el paquete ha sido actualizado para utilizar **Pydantic v2**, lo que implica que las versiones anteriores ya no recibirán soporte ni actualizaciones. -A partir de la versión 2.0.0, el paquete se actualizará a **Pydantic v2**, lo que significa que las versiones anteriores ya no recibirán soporte. +No obstante, en versiones anteriores hemos agregado una función que permite añadir bancos adicionales a la lista sin necesidad de crear un PR. Esto es útil para quienes aún utilicen versiones anteriores. Sin embargo, a partir de la versión 2, continuaremos manteniendo y actualizando la lista oficial de bancos mediante PRs en el repositorio. -Sin embargo, hemos añadido una función para agregar bancos adicionales a la lista, en caso de que sea necesario. Esto se puede hacer sin necesidad de crear un PR. Para agregar un banco, simplemente llama a la siguiente función con el código de Banxico y el nombre del banco: +### Cómo agregar un banco + +Para agregar un banco, llama a la función `add_bank` pasando el código de Banxico y el nombre del banco como parámetros. ```python import clabe clabe.add_bank('12345', 'New Bank') ``` -Para eliminar un banco +### Cómo eliminar un banco + +De manera similar, puedes eliminar un banco llamando a la función remove_bank con el código del banco que deseas eliminar. ```python import clabe clabe.remove_bank('12345') ``` + +**Nota**: Aunque estas funciones están disponibles para un uso más flexible, recomendamos utilizar siempre la lista oficial de bancos actualizada en la versión 2+. + +## Mantener actualizada la lista de participantes + +Para mantener sincronizada la lista de participantes del SPEI con la información oficial de Banxico, este repositorio incluye un script que compara automáticamente los datos locales con la lista actualizada de instituciones financieras. + +### Verificar cambios en la lista de participantes + +El script `compare_banks.py` detecta: + +- **Nuevos participantes**: Instituciones que aparecen en Banxico pero no están en el paquete +- **Bajas de participantes**: Instituciones que ya no aparecen en la lista oficial de Banxico +- **Cambios de nombre**: Participantes que mantienen el mismo código pero han cambiado su nombre + +Para ejecutar la verificación: + +```bash +export PYTHONPATH=$(pwd) +python scripts/compare_banks.py +``` + +### Interpretar los resultados + +El script mostrará un reporte con las diferencias encontradas: + +- **ADDITIONS**: Nuevos participantes que deben agregarse al archivo `clabe/banks.py` +- **REMOVALS**: Participantes que deben eliminarse por ya no estar activos +- **CHANGES**: Cambios de nombre que requieren actualización + +Si no hay diferencias, el script confirmará que los datos están sincronizados. + +### Actualizar la lista de participantes + +Una vez identificados los cambios, actualiza manualmente el archivo `clabe/banks.py` y crea un pull request para mantener la lista actualizada. \ No newline at end of file diff --git a/clabe/banks.py b/clabe/banks.py index 3093337..3d71d73 100644 --- a/clabe/banks.py +++ b/clabe/banks.py @@ -40,10 +40,10 @@ '140': '40140', '652': '90652', '688': '90688', - '126': '40126', '680': '90680', '723': '90723', '722': '90722', + '720': '90720', '151': '40151', '616': '90616', '634': '90634', @@ -58,7 +58,6 @@ '902': '90902', '150': '40150', '136': '40136', - '686': '90686', '059': '40059', '110': '40110', '661': '90661', @@ -70,7 +69,6 @@ '600': '90600', '108': '40108', '132': '40132', - '613': '90613', '135': '37135', '710': '90710', '684': '90684', @@ -82,28 +80,35 @@ '157': '40157', '728': '90728', '646': '90646', - '648': '90648', '656': '90656', '617': '90617', '605': '90605', - '608': '90608', '703': '90703', '113': '40113', '141': '40141', + '715': '90715', + '732': '90732', + '734': '90734', + '167': '40167', + '721': '90721', + '727': '90727', + '738': '90738', + '730': '90730', } # Descargado de https://www.banxico.org.mx/cep-scl/listaInstituciones.do # 2022-10-18 +# The order of the banks must be alphabetical by bank name BANK_NAMES = { - '40138': 'ABC Capital', '40133': 'Actinver', '40062': 'Afirme', - '90706': 'Arcus', + '90721': 'Albo', + '90706': 'Arcus Fi', '90659': 'Asp Integra Opc', '40128': 'Autofin', '40127': 'Azteca', - '37166': 'Babien', + '37166': 'BaBien', '40030': 'Bajio', '40002': 'Banamex', '40154': 'Banco Covalto', @@ -126,9 +131,10 @@ '40112': 'Bmonex', '90677': 'Caja Pop Mexica', '90683': 'Caja Telefonist', + '90715': 'Cashi Cuenta', '90630': 'CB Intercam', - '40124': 'CBM Banco', - '40143': 'CI Banco', + '40124': 'Citi Mexico', + '40143': 'CIBanco', '90631': 'CI Bolsa', '90901': 'Cls', '90903': 'CoDi Valida', @@ -136,16 +142,18 @@ '40140': 'Consubanco', '90652': 'Credicapital', '90688': 'Crediclub', - '40126': 'Credit Suisse', '90680': 'Cristobal Colon', '90723': 'Cuenca', '40151': 'Donde', '90616': 'Finamex', '90634': 'Fincomun', + '90734': 'Finco Pay', + '90738': 'Fintoc', '90689': 'Fomped', '90699': 'Fondeadora', '90685': 'Fondo (Fira)', '90601': 'Gbm', + '40167': 'Hey Banco', '37168': 'Hipotecaria Fed', '40021': 'HSBC', '40155': 'Icbc', @@ -153,7 +161,6 @@ '90902': 'Indeval', '40150': 'Inmobiliario', '40136': 'Intercam Banco', - '90686': 'Invercap', '40059': 'Invex', '40110': 'JP Morgan', '90661': 'KLAR', @@ -161,16 +168,17 @@ '90670': 'Libertad', '90602': 'Masari', '90722': 'Mercado Pago W', + '90720': 'MexPago', '40042': 'Mifel', '40158': 'Mizuho Bank', '90600': 'Monexcb', '40108': 'Mufg', '40132': 'Multiva Banco', - '90613': 'Multiva Cbolsa', '37135': 'Nafin', '90638': 'NU MEXICO', '90710': 'NVIO', '40148': 'Pagatodo', + '90732': 'Peibo', '90620': 'Profuturo', '40156': 'Sabadell', '40014': 'Santander', @@ -178,13 +186,14 @@ '40157': 'Shinhan', '90728': 'Spin by OXXO', '90646': 'STP', - '90648': 'Tactiv Cb', + '90730': 'Swap', '90703': 'Tesored', '90684': 'Transfer', + '90727': 'Transfer directo', + '40138': 'Uala', '90656': 'Unagra', '90617': 'Valmex', '90605': 'Value', - '90608': 'Vector', '40113': 'Ve Por Mas', '40141': 'Volkswagen', } diff --git a/clabe/errors.py b/clabe/errors.py deleted file mode 100644 index 6ecd610..0000000 --- a/clabe/errors.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic.errors import PydanticValueError - - -class BankCodeValidationError(PydanticValueError): - code = 'clabe.bank_code' - msg_template = 'código de banco no es válido' - - -class ClabeControlDigitValidationError(PydanticValueError): - code = 'clabe.control_digit' - msg_template = 'clabe dígito de control no es válido' diff --git a/clabe/types.py b/clabe/types.py index 08d6052..192661f 100644 --- a/clabe/types.py +++ b/clabe/types.py @@ -1,23 +1,11 @@ -from typing import TYPE_CHECKING, ClassVar +from typing import Any, Dict, Type -from pydantic.errors import NotDigitError -from pydantic.validators import ( - constr_length_validator, - constr_strip_whitespace, - str_validator, -) +from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic_core import PydanticCustomError, core_schema -from .errors import BankCodeValidationError, ClabeControlDigitValidationError from .validations import BANK_NAMES, BANKS, compute_control_digit -if TYPE_CHECKING: - from pydantic.typing import CallableGenerator - - -def validate_digits(v: str) -> str: - if not v.isdigit(): - raise NotDigitError - return v +CLABE_LENGTH = 18 class Clabe(str): @@ -25,37 +13,55 @@ class Clabe(str): Based on: https://es.wikipedia.org/wiki/CLABE """ - strip_whitespace: ClassVar[bool] = True - min_length: ClassVar[int] = 18 - max_length: ClassVar[int] = 18 - - def __init__(self, clabe: str): + def __init__(self, clabe: str) -> None: self.bank_code_abm = clabe[:3] self.bank_code_banxico = BANKS[clabe[:3]] self.bank_name = BANK_NAMES[self.bank_code_banxico] + @property + def bank_code(self) -> str: + return self.bank_code_banxico + @classmethod - def __get_validators__(cls) -> 'CallableGenerator': - yield str_validator - yield constr_strip_whitespace - yield constr_length_validator - yield validate_digits - yield cls.validate_bank_code_abm - yield cls.validate_control_digit - yield cls + def __get_pydantic_json_schema__( + cls, + schema: core_schema.CoreSchema, + handler: GetJsonSchemaHandler, + ) -> Dict[str, Any]: + json_schema = handler(schema) + json_schema.update( + type="string", + pattern="^[0-9]{18}$", + description="CLABE (Clave Bancaria Estandarizada)", + examples=["723010123456789019"], + ) + return json_schema @classmethod - def validate_bank_code_abm(cls, clabe: str) -> str: - if clabe[:3] not in BANKS.keys(): - raise BankCodeValidationError - return clabe + def __get_pydantic_core_schema__( + cls, + _: Type[Any], + __: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + return core_schema.no_info_after_validator_function( + cls._validate, + core_schema.str_schema( + min_length=CLABE_LENGTH, + max_length=CLABE_LENGTH, + strip_whitespace=True, + ), + ) @classmethod - def validate_control_digit(cls, clabe: str) -> str: + def _validate(cls, clabe: str) -> 'Clabe': + if not clabe.isdigit(): + raise PydanticCustomError('clabe', 'debe ser numérico') + if clabe[:3] not in BANKS: + raise PydanticCustomError( + 'clabe.bank_code', 'código de banco no es válido' + ) if clabe[-1] != compute_control_digit(clabe): - raise ClabeControlDigitValidationError - return clabe - - @property - def bank_code(self): - return self.bank_code_banxico + raise PydanticCustomError( + 'clabe.control_digit', 'clabe dígito de control no es válido' + ) + return cls(clabe) diff --git a/clabe/validations.py b/clabe/validations.py index d4488c4..1a112e5 100644 --- a/clabe/validations.py +++ b/clabe/validations.py @@ -81,7 +81,8 @@ class BankConfigRequest(BaseModel): ) bank_code_banxico: str = Field( - regex=r"^\d{5}$", description="Banxico code must be a 5-digit string." + pattern=r"^\d{5}$", + description="Banxico code must be a 5-digit string.", ) @property diff --git a/clabe/version.py b/clabe/version.py index d2309e3..da04cc3 100644 --- a/clabe/version.py +++ b/clabe/version.py @@ -1 +1 @@ -__version__ = '1.2.17' +__version__ = '2.1.6' diff --git a/requirements-test.txt b/requirements-test.txt index 41c2ffb..79e67c0 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -4,3 +4,4 @@ black==22.8.0 isort==5.11.5 flake8==5.0.4 mypy==1.4.1 +requests==2.32.3 diff --git a/requirements.txt b/requirements.txt index 62c77cd..e142a8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -pydantic==1.9.0 +pydantic==2.10.3 diff --git a/scripts/compare_banks.py b/scripts/compare_banks.py new file mode 100644 index 0000000..6f3660e --- /dev/null +++ b/scripts/compare_banks.py @@ -0,0 +1,70 @@ +from datetime import datetime + +import requests +from clabe.banks import BANK_NAMES + + +def fetch_banxico_data(): + current_date = datetime.now().strftime('%d-%m-%Y') + url = f'https://www.banxico.org.mx/cep/instituciones.do?fecha={current_date}' + try: + response = requests.get(url) + response.raise_for_status() + data = response.json() + return dict(data.get('instituciones', [])) + except Exception as e: + print(f'Error fetching data from Banxico: {e}') + return {} + + +def compare_bank_data(): + current_banks = dict(BANK_NAMES) + banxico_banks = fetch_banxico_data() + + differences = {'additions': {}, 'removals': {}, 'changes': {}} + + print('Comparing bank data...\n') + + # Check for additions (in Banxico but not in package) + additions = { + code: name for code, name in banxico_banks.items() if code not in current_banks + } + differences['additions'] = additions + if additions: + print('=== ADDITIONS (in Banxico but not in package) ===') + for code, name in sorted(additions.items()): + print(f' {code}: {name}') + print() + + # Check for removals (in package but not in Banxico) + removals = { + code: name for code, name in current_banks.items() if code not in banxico_banks + } + differences['removals'] = removals + if removals: + print('=== REMOVALS (in package but not in Banxico) ===') + for code, name in sorted(removals.items()): + print(f' {code}: {name}') + print() + + # Check for changes (different names for the same code) + changes = { + code: (current_banks[code], banxico_banks[code]) + for code in set(current_banks) & set(banxico_banks) + if current_banks[code].upper() != banxico_banks[code].upper() + } + differences['changes'] = changes + if changes: + print('=== CHANGES (different names for the same code): Package -> Banxico ===') + for code, (current_name, banxico_name) in sorted(changes.items()): + print(f' {code}: {current_name} -> {banxico_name}') + print() + + if not additions and not removals and not changes: + print('No differences found. The data is in sync.') + + return differences + + +if __name__ == '__main__': + differences = compare_bank_data() diff --git a/setup.py b/setup.py index 80d79db..f2e7bc9 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,8 @@ packages=setuptools.find_packages(), include_package_data=True, package_data=dict(clabe=['py.typed']), - install_requires=['pydantic>=1.4,<2.0'], + python_requires='>=3.8', + install_requires=['pydantic>=2.10.3'], classifiers=[ 'Programming Language :: Python :: 3', 'License :: OSI Approved :: MIT License', diff --git a/tests/test_types.py b/tests/test_types.py index 543ea23..2fa7bb0 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,15 +1,10 @@ import pytest -from pydantic import BaseModel -from pydantic.errors import NotDigitError +from pydantic import BaseModel, ValidationError -from clabe import BANK_NAMES, BANKS, compute_control_digit -from clabe.errors import ( - BankCodeValidationError, - ClabeControlDigitValidationError, -) -from clabe.types import Clabe, validate_digits +from clabe import BANK_NAMES, BANKS +from clabe.types import Clabe -VALID_CLABE = '646180157042875763' +VALID_CLABE = '723123456682660854' class Cuenta(BaseModel): @@ -18,29 +13,58 @@ class Cuenta(BaseModel): def test_valid_clabe(): cuenta = Cuenta(clabe=VALID_CLABE) - assert cuenta.clabe.bank_code_abm == '646' - assert cuenta.clabe.bank_code_banxico == BANKS['646'] - assert cuenta.clabe.bank_name == BANK_NAMES[BANKS['646']] + assert cuenta.clabe.bank_code_abm == '723' + assert cuenta.clabe.bank_code_banxico == BANKS['723'] + assert cuenta.clabe.bank_name == BANK_NAMES[BANKS['723']] assert cuenta.clabe.bank_code == cuenta.clabe.bank_code_banxico -def test_clabe_digits(): - assert validate_digits(VALID_CLABE) - - -def test_clabe_not_digit(): - with pytest.raises(NotDigitError): - validate_digits('h' * 18) - +@pytest.mark.parametrize( + 'clabe,expected_message', + [ + pytest.param( + 'h' * 18, + 'debe ser numérico', + id='clabe_not_digit', + ), + pytest.param( + '9' * 17, + 'String should have at least 18 characters', + id='invalid_bank_length', + ), + pytest.param( + '9' * 19, + 'String should have at most 18 characters', + id='invalid_bank_length', + ), + pytest.param( + '111180157042875763', + 'código de banco no es válido', + id='invalid_bank_code', + ), + pytest.param( + '001' + '9' * 15, + 'clabe dígito de control no es válido', + id='invalid_control_digit', + ), + ], +) +def test_invalid_clabe(clabe: Clabe, expected_message: str) -> None: + with pytest.raises(ValidationError) as exc: + Cuenta(clabe=clabe) + assert expected_message in str(exc.value) -def test_invalid_bank_code_abm(): - clabe = '9' * 17 - clabe += compute_control_digit(clabe) - with pytest.raises(BankCodeValidationError): - Clabe.validate_bank_code_abm(clabe) +def test_get_json_schema() -> None: + from pydantic import TypeAdapter -def test_invalid_control_digit(): - clabe = '001' + '9' * 15 - with pytest.raises(ClabeControlDigitValidationError): - Clabe.validate_control_digit(clabe) + adapter = TypeAdapter(Clabe) + schema = adapter.json_schema() + assert schema == { + 'description': 'CLABE (Clave Bancaria Estandarizada)', + 'examples': ['723010123456789019'], + 'maxLength': 18, + 'minLength': 18, + 'pattern': '^[0-9]{18}$', + 'type': 'string', + }