From 854931f4780829b84066b0cd1e3e43120d476973 Mon Sep 17 00:00:00 2001 From: laurazimrn Date: Tue, 25 Aug 2026 15:33:55 -0300 Subject: [PATCH] =?UTF-8?q?Adding=20CNS=20(Cart=C3=A3o=20Nacional=20de=20S?= =?UTF-8?q?a=C3=BAde)=20validator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #774 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 ++ README.md | 102 ++++++++++++++++++++++++ README_EN.md | 95 ++++++++++++++++++++++ brutils/__init__.py | 11 +++ brutils/cns.py | 187 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_cns.py | 97 +++++++++++++++++++++++ 6 files changed, 499 insertions(+) create mode 100644 brutils/cns.py create mode 100644 tests/test_cns.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e97d4fd2..6810488b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Utilitário `is_valid_cns` [#774](https://github.com/brazilian-utils/python/issues/774) +- Utilitário `generate_cns` [#774](https://github.com/brazilian-utils/python/issues/774) +- Utilitário `format_cns` [#774](https://github.com/brazilian-utils/python/issues/774) +- Utilitário `remove_symbols_cns` [#774](https://github.com/brazilian-utils/python/issues/774) + ## [2.5.0] - 2026-06-30 ### Added diff --git a/README.md b/README.md index 86dad487..54a43671 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,11 @@ False - [convert\_date\_to_text](#convert_date_to_text) - [CNH](#cnh) - [is\_valid\_cnh](#is_valid_cnh) +- [CNS](#cns) + - [is\_valid\_cns](#is_valid_cns) + - [format\_cns](#format_cns) + - [remove\_symbols\_cns](#remove_symbols_cns) + - [generate\_cns](#generate_cns) - [Placa de Carro](#placa-de-carro) - [is\_valid\_license\_plate](#is_valid_license_plate) - [format\_license\_plate](#format_license_plate) @@ -727,6 +732,103 @@ True True ``` +## CNS + +### is_valid_cns + +Verifica se o número do CNS (Cartão Nacional de Saúde) é válido. Apenas números, formatado como +uma string, com o tamanho correto (15 dígitos). Não verifica se o CNS realmente existe. + +Referências: + +- . +- . + +Argumentos: + +- cns (str): número do CNS como uma string do tamanho correto. + +Retorno: + +- bool: True se o CNS for válido, False caso contrário. + +Exemplo: + +```python +from brutils import is_valid_cns +>>> is_valid_cns("161243374450004") +True +>>> is_valid_cns("123456789012345") +False +``` + +### format_cns + +Formata uma string de CNS (Cartão Nacional de Saúde) válida com os símbolos visuais padrão de +exibição. + +Argumentos: + +- cns (str): uma string de CNS válida contendo apenas números. + +Retorno: + +- str: uma string de CNS formatada com os símbolos visuais padrão ou None caso a entrada seja + inválida. + +Exemplo: + +```python +from brutils import format_cns +>>> format_cns("161243374450004") +'161 2433 7445 0004' +``` + +### remove_symbols_cns + +Esta função recebe uma string de CNS (Cartão Nacional de Saúde) com símbolos de formatação e +retorna uma versão limpa sem determinados símbolos. Remove intencionalmente apenas os símbolos +"-", "." e " ", deixando os demais símbolos intactos. + +Argumentos: + +- cns (str): uma string de CNS que pode conter símbolos de formatação. + +Retorno: + +- str: uma string de CNS limpa, sem símbolos de formatação. + +Exemplo: + +```python +from brutils import remove_symbols_cns +>>> remove_symbols_cns("161 2433 7445 0004") +'161243374450004' +``` + +### generate_cns + +Gera uma string de dígitos contendo um número de CNS brasileiro válido aleatório. Pode gerar um +CNS "definitivo" (iniciado em 1 ou 2) ou "provisório" (iniciado em 7, 8 ou 9). + +Argumentos: + +- is_final (bool): se deve gerar um CNS definitivo. Padrão é True. + +Retorno: + +- str: um número de CNS válido gerado aleatoriamente, como uma string. + +Exemplo: + +```python +from brutils import generate_cns +>>> generate_cns() +'161243374450004' +>>> generate_cns(is_final=False) +'905885616557480' +``` + ## Placa de Carro diff --git a/README_EN.md b/README_EN.md index 0df33c5e..a8352115 100644 --- a/README_EN.md +++ b/README_EN.md @@ -75,6 +75,11 @@ False - [is\_valid\_email](#is_valid_email) - [CNH](#cnh) - [is\_valid\_cnh](#is_valid_cnh) +- [CNS](#cns) + - [is\_valid\_cns](#is_valid_cns) + - [format\_cns](#format_cns) + - [remove\_symbols\_cns](#remove_symbols_cns) + - [generate\_cns](#generate_cns) - [License Plate](#license-plate) - [is\_valid\_license\_plate](#is_valid_license_plate) - [format\_license\_plate](#format_license_plate) @@ -719,6 +724,96 @@ True True ``` +## CNS + +### is_valid_cns + +Verifies if the CNS (Cartão Nacional de Saúde) number is valid. Only numbers, formatted as a string, with proper length (15 digits). It does not check if the CNS actually exists. + +References: + +- . +- . + +Args: + +- cns (str): CNS number as a string of proper length. + +Returns: + +- bool: True if the CNS is valid, False otherwise. + +Example: + +```python +from brutils import is_valid_cns +>>> is_valid_cns("161243374450004") +True +>>> is_valid_cns("123456789012345") +False +``` + +### format_cns + +Formats a valid CNS (Cartão Nacional de Saúde) string with standard visual aid symbols for display. + +Args: + +- cns (str): A valid string of CNS containing only numbers. + +Returns: + +- str: A formatted CNS string with standard visual aid symbols or None if the input is invalid. + +Example: + +```python +from brutils import format_cns +>>> format_cns("161243374450004") +'161 2433 7445 0004' +``` + +### remove_symbols_cns + +This function takes a string of CNS (Cartão Nacional de Saúde) with formatting symbols and returns a clean version without certain symbols. It intentionally removes only the symbols "-", "." and " ", leaving other symbols untouched. + +Args: + +- cns (str): A string of CNS that may contain formatting symbols. + +Returns: + +- str: A clean string of CNS without formatting symbols. + +Example: + +```python +from brutils import remove_symbols_cns +>>> remove_symbols_cns("161 2433 7445 0004") +'161243374450004' +``` + +### generate_cns + +Generates a string of digits containing a random valid Brazilian CNS number. Can generate either a "definitive" CNS (starting with 1 or 2) or a "provisional" one (starting with 7, 8 or 9). + +Args: + +- is_final (bool): Whether to generate a definitive CNS. Defaults to True. + +Returns: + +- str: A randomly generated valid CNS number as a string. + +Example: + +```python +from brutils import generate_cns +>>> generate_cns() +'161243374450004' +>>> generate_cns(is_final=False) +'905885616557480' +``` ## License Plate diff --git a/brutils/__init__.py b/brutils/__init__.py index ddb264e4..45e7f5f9 100644 --- a/brutils/__init__.py +++ b/brutils/__init__.py @@ -17,6 +17,12 @@ from brutils.cnpj import is_valid as is_valid_cnpj from brutils.cnpj import remove_symbols as remove_symbols_cnpj +# CNS Imports +from brutils.cns import format_cns +from brutils.cns import generate as generate_cns +from brutils.cns import is_valid as is_valid_cns +from brutils.cns import remove_symbols as remove_symbols_cns + # CPF Imports from brutils.cpf import format_cpf from brutils.cpf import generate as generate_cpf @@ -114,6 +120,11 @@ "remove_symbols_cpf", # CNH "is_valid_cnh", + # CNS + "format_cns", + "generate_cns", + "is_valid_cns", + "remove_symbols_cns", # Email "is_valid_email", # Legal Process diff --git a/brutils/cns.py b/brutils/cns.py new file mode 100644 index 00000000..76a839b3 --- /dev/null +++ b/brutils/cns.py @@ -0,0 +1,187 @@ +import re +from random import randint + +# CNS numbers are 15 digits long. The check digit(s) are calculated as a +# weighted sum of the digits, using decreasing weights starting at 15. +WEIGHTS = list(range(15, 0, -1)) + +# Definitive CNS: starts with 1 or 2, followed by 10 digits (the 11-digit +# block is derived from a PIS/PASEP-like base), then a fixed "00" segment, +# a flag digit ("0" or "1") and the check digit. +_DEFINITIVE_REGEX = re.compile(r"^[12]\d{10}00[01]\d$") + +# Provisional CNS: starts with 7, 8 or 9, followed by 13 digits and a check +# digit, all 15 digits taking part in the weighted sum. +_PROVISIONAL_REGEX = re.compile(r"^[789]\d{14}$") + + +# FORMATTING +############ + + +def remove_symbols(cns: str) -> str: + """ + Remove formatting symbols from a CNS. + + This function takes a CNS (Cartão Nacional de Saúde) string with + formatting symbols and returns a cleaned version with no symbols. + + Args: + cns (str): A CNS string that may contain formatting symbols. + + Returns: + str: A cleaned CNS string with no formatting symbols. + + Example: + >>> remove_symbols("898 0032 6314 4970") + '898003263144970' + >>> remove_symbols("898003263144970") + '898003263144970' + """ + return cns.replace(" ", "").replace(".", "").replace("-", "") + + +def format_cns(cns: str) -> str | None: + """ + Format a valid CNS (Cartão Nacional de Saúde) string with standard + visual aid symbols. + + This function takes a valid numbers-only CNS string as input and adds + the standard visual grouping used on the physical Cartão SUS. + + Args: + cns (str): A valid numbers-only CNS string. + + Returns: + str: A formatted CNS string with standard visual aid symbols + or None if the input is invalid. + + Example: + >>> format_cns("898003263144970") + '898 0032 6314 4970' + """ + + if not is_valid(cns): + return None + + return "{} {} {} {}".format(cns[:3], cns[3:7], cns[7:11], cns[11:15]) + + +# OPERATIONS +############ + + +def is_valid(cns: str) -> bool: + """ + Returns whether or not the given `CNS` is valid. + + It does not verify if the CNS actually exists. + + References: + - https://gist.github.com/dudanogueira/7af722477c33bd4bb85843cf0e035b77 + - https://integracao.esusaps.bridge.ufsc.tech/v211/docs/algoritmo_CNS.html + + Args: + cns (str): CNS number as a string of proper length. + + Returns: + bool: True if CNS is valid, False otherwise. + + Example: + >>> is_valid("161243374450004") + True + >>> is_valid("905885616557480") + True + >>> is_valid("123456789012345") + False + """ + + if not isinstance(cns, str) or not cns.isdigit() or len(cns) != 15: + return False + + if not (_DEFINITIVE_REGEX.match(cns) or _PROVISIONAL_REGEX.match(cns)): + return False + + return _weighted_sum(cns) % 11 == 0 + + +def generate(is_final: bool = True) -> str: + """ + Generate a random valid Brazilian CNS number. + + Args: + is_final (bool): Whether to generate a "definitive" CNS (starting + with 1 or 2) or a "provisional" one (starting with 7, 8 or 9). + Defaults to True (definitive). + + Returns: + str: A randomly generated valid CNS number as a string. + + Example: + >>> generate() + '161243374450004' + >>> generate(is_final=False) + '905885616557480' + """ + + return _generate_definitive() if is_final else _generate_provisional() + + +def _weighted_sum(digits: str) -> int: + """ + Calculate the weighted sum of the given digits, using decreasing + weights starting at 15 (i.e. the first digit is always weighted 15, + regardless of the total length of `digits`). + + Args: + digits (str): A string of digits. + + Returns: + int: The weighted sum of the digits. + """ + return sum(int(digit) * weight for digit, weight in zip(digits, WEIGHTS)) + + +def _generate_definitive() -> str: + """ + Generate a random valid "definitive" CNS (starting with 1 or 2). + + Returns: + str: A randomly generated valid definitive CNS number as a string. + """ + base = str(randint(1, 2)) + str(randint(0, 9999999999)).zfill(10) + + soma = _weighted_sum(base) + resto = soma % 11 + dv = 11 - resto + + if dv == 11: + dv = 0 + seq = "000" + elif dv == 10: + # Special case: recalculate with an offset of 2 and mark the + # sequence segment accordingly. + soma += 2 + dv = 11 - (soma % 11) + seq = "001" + else: + seq = "000" + + return f"{base}{seq}{dv}" + + +def _generate_provisional() -> str: + """ + Generate a random valid "provisional" CNS (starting with 7, 8 or 9). + + Returns: + str: A randomly generated valid provisional CNS number as a string. + """ + while True: + base = str(randint(7, 9)) + str(randint(0, 10**13 - 1)).zfill(13) + dv = (11 - (_weighted_sum(base) % 11)) % 11 + + # A check digit of 10 cannot be represented by a single digit, so + # a new base must be generated in that (rare) case. + if dv != 10: + return f"{base}{dv}" diff --git a/tests/test_cns.py b/tests/test_cns.py new file mode 100644 index 00000000..2ac6b876 --- /dev/null +++ b/tests/test_cns.py @@ -0,0 +1,97 @@ +from unittest import TestCase, main +from unittest.mock import patch + +from brutils.cns import ( + _generate_definitive, + _generate_provisional, + _weighted_sum, + format_cns, + generate, + is_valid, + remove_symbols, +) + + +class TestCNS(TestCase): + def test_is_valid(self): + # When CNS is not a string, returns False + self.assertIs(is_valid(1), False) + self.assertIs(is_valid([]), False) + self.assertIs(is_valid({}), False) + self.assertIs(is_valid(None), False) + + # When CNS's len is different of 15, returns False + self.assertIs(is_valid("12345678901234"), False) + self.assertIs(is_valid("1234567890123456"), False) + + # When CNS does not contain only digits, returns False + self.assertIs(is_valid("12345678901234x"), False) + + # When the first digit does not match a known CNS format, returns False + self.assertIs(is_valid("323456789012345"), False) + self.assertIs(is_valid("000000000000000"), False) + + # When the "definitive" fixed segment ("00" + flag digit) is wrong, + # returns False even if some digit sequence looks plausible + self.assertIs(is_valid("123456789012345"), False) + + # When checksum digit doesn't match, returns False + self.assertIs(is_valid("161243374450005"), False) + self.assertIs(is_valid("905885616557481"), False) + + # When CNS is valid (definitive, starts with 1 or 2) + self.assertIs(is_valid("161243374450004"), True) + + # When CNS is valid (provisional, starts with 7, 8 or 9) + self.assertIs(is_valid("905885616557480"), True) + + def test_weighted_sum(self): + self.assertEqual(_weighted_sum("161243374450004"), 374) + self.assertEqual(_weighted_sum("12345678901"), 440) + + def test_generate_definitive(self): + for _ in range(10_000): + cns = _generate_definitive() + self.assertIs(is_valid(cns), True) + self.assertIn(cns[0], ("1", "2")) + + def test_generate_provisional(self): + for _ in range(10_000): + cns = _generate_provisional() + self.assertIs(is_valid(cns), True) + self.assertIn(cns[0], ("7", "8", "9")) + + def test_generate(self): + self.assertIn(generate()[0], ("1", "2")) + self.assertIn(generate(is_final=False)[0], ("7", "8", "9")) + self.assertIs(is_valid(generate()), True) + self.assertIs(is_valid(generate(is_final=False)), True) + + def test_remove_symbols(self): + self.assertEqual( + remove_symbols("898 0032 6314 4970"), "898003263144970" + ) + self.assertEqual(remove_symbols("898003263144970"), "898003263144970") + self.assertEqual(remove_symbols("134..2435/.-1892.-"), "1342435/1892") + self.assertEqual(remove_symbols("...---..."), "") + + @patch("brutils.cns.is_valid") + def test_format_valid_cns(self, mock_is_valid): + mock_is_valid.return_value = True + + # When CNS is_valid, returns formatted CNS + self.assertEqual(format_cns("161243374450004"), "161 2433 7445 0004") + + # Checks if function is_valid is called + mock_is_valid.assert_called_once_with("161243374450004") + + @patch("brutils.cns.is_valid") + def test_format_invalid_cns(self, mock_is_valid): + mock_is_valid.return_value = False + + # When CNS isn't valid, returns None + self.assertIsNone(format_cns("161243374450004")) + + +if __name__ == "__main__": + main()