From 854931f4780829b84066b0cd1e3e43120d476973 Mon Sep 17 00:00:00 2001 From: laurazimrn Date: Tue, 25 Aug 2026 15:33:55 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Adding=20CNS=20(Cart=C3=A3o=20Nacional=20de?= =?UTF-8?q?=20Sa=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() From 5a145f8e8dd3768e0e093f2d18ef673d98480937 Mon Sep 17 00:00:00 2001 From: Gabrieldiv Date: Mon, 2 Nov 2026 06:29:41 -0300 Subject: [PATCH 2/2] fix: reject definitive CNS whose suffix is not derived from the first 11 digits A definitive CNS could pass validation when the weighted sum was divisible by 11 even if its last four digits were not the ones the algorithm derives (e.g. 161243374450012). The suffix calculation was moved from _generate_definitive to a new helper, _definitive_suffix, now used by both generation and validation. --- brutils/cns.py | 85 +++++++++++++++++++++++++++++++---------------- tests/test_cns.py | 4 +++ 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/brutils/cns.py b/brutils/cns.py index 76a839b3..75a5755c 100644 --- a/brutils/cns.py +++ b/brutils/cns.py @@ -43,22 +43,21 @@ def remove_symbols(cns: str) -> str: def format_cns(cns: str) -> str | None: """ - Format a valid CNS (Cartão Nacional de Saúde) string with standard - visual aid symbols. + Return the correct four-character suffix string of a definitive CNS, + derived from its first eleven digits. - This function takes a valid numbers-only CNS string as input and adds - the standard visual grouping used on the physical Cartão SUS. + It is used by both `is_valid` and `_generate_definitive`, so validating + and generating a definitive CNS follow the same rule, which keeps them consistent. Args: - cns (str): A valid numbers-only CNS string. + base (str): The first eleven digits of a definitive CNS (starting with 1 or 2). Returns: - str: A formatted CNS string with standard visual aid symbols - or None if the input is invalid. + str: A four-character string with the segment ("000" or "001") and the check digit. Example: - >>> format_cns("898003263144970") - '898 0032 6314 4970' + >>> _definitive_suffix("16124337445") + '0004' """ if not is_valid(cns): @@ -71,6 +70,46 @@ def format_cns(cns: str) -> str | None: ############ +def _definitive_suffix(value: str) -> str: + """ + Return the four-character suffix of a definitive CNS, derived from its + first eleven digits: the three-digit segment ("000", or "001" in the + special case) followed by the check digit. + + It is used by both `is_valid` and `_generate_definitive`, so validating + and generating a definitive CNS follow the same rule. + + Args: + base (str): The first eleven digits of a definitive CNS (starting + with 1 or 2). + + Returns: + str: A four-character string with the segment and the check digit. + + Example: + >>> _definitive_suffix("16124337445") + '0004' + """ + + soma = _weighted_sum(value) + 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"{seq}{dv}" + + def is_valid(cns: str) -> bool: """ Returns whether or not the given `CNS` is valid. @@ -102,7 +141,13 @@ def is_valid(cns: str) -> bool: if not (_DEFINITIVE_REGEX.match(cns) or _PROVISIONAL_REGEX.match(cns)): return False - return _weighted_sum(cns) % 11 == 0 + if cns[0] == "1" or cns[0] == "2": + prefix = cns[:11] + suffix = cns[-4:] + verification = _definitive_suffix(prefix) + return verification == suffix + else: + return _weighted_sum(cns) % 11 == 0 def generate(is_final: bool = True) -> str: @@ -150,24 +195,8 @@ def _generate_definitive() -> str: 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}" + final = _definitive_suffix(base) + return f"{base}{final}" def _generate_provisional() -> str: diff --git a/tests/test_cns.py b/tests/test_cns.py index 2ac6b876..ba94ea77 100644 --- a/tests/test_cns.py +++ b/tests/test_cns.py @@ -39,6 +39,10 @@ def test_is_valid(self): self.assertIs(is_valid("161243374450005"), False) self.assertIs(is_valid("905885616557481"), False) + # When the definitive suffix isn't the one derived from the first + # 11 digits, returns False even if the weighted sum is a multiple of 11 + self.assertIs(is_valid("161243374450012"), False) + # When CNS is valid (definitive, starts with 1 or 2) self.assertIs(is_valid("161243374450004"), True)