diff --git a/CHANGELOG.md b/CHANGELOG.md index 34177df..25f83f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Utilitário `format_currency` [#434](https://github.com/brazilian-utils/brutils-python/pull/434) - Utilitário `convert_real_to_text` [#525](https://github.com/brazilian-utils/brutils-python/pull/525) - Utilitário `convert_uf_to_name` [#554](https://github.com/brazilian-utils/brutils-python/pull/554) +- Utilitário `is_valid_passport` [#579](https://github.com/brazilian-utils/brutils-python/issues/579) +- Utilitário `format_passport` [#579](https://github.com/brazilian-utils/brutils-python/issues/579) +- Utilitário `remove_symbols_passport` [#579](https://github.com/brazilian-utils/brutils-python/issues/579) +- Utilitário `generate_passport` [#579](https://github.com/brazilian-utils/brutils-python/issues/579) ### Deprecated diff --git a/README.md b/README.md index 9cd5c52..c166770 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,11 @@ False - [Monetário](#monetário) - [format\_currency](#format_currency) - [convert\_real\_to\_text](#convert_real_to_text) +- [Passaporte](#passaporte) + - [is_valid_passport](#is_valid_passport) + - [format_passport](#format_passport-1) + - [remove_symbols_passport](#remove_symbols_passport) + - [generate_passport](#generate_passport-1) ## CPF @@ -1312,6 +1317,97 @@ Exemplo: >>> convert_real_to_text("invalid") None ``` +## Passaporte + +### is_valid_passport + +Validate the format of a Brazilian Passport number. +The input must have exactly two letters followed by six digits. +This function does not check if the Passport actually exists. + +Args: + +- passport (str): A Passport string. + +Returns: + +- bool: True if the format is valid, False otherwise. + +Example: + +```python +>>> from brutils import is_valid_passport +>>> is_valid_passport("CS265436") +True +>>> is_valid_passport("CS-265.436") +False +``` + +### format_passport + +Normaliza uma string de Passaporte para exibição. + +Esta função recebe uma string de Passaporte (possivelmente com símbolos visuais como espaços, pontos ou traços), remove esses símbolos se necessário, e retorna a string em maiúsculas se for válida. + +Argumentos: + +- passport (str): Uma string de Passaporte com ou sem símbolos. + +Retorna: + +- str: Uma string de Passaporte em maiúsculas sem símbolos se for válida, + `None` se for inválida. + +Exemplo: + +```python +>>> from brutils import format_passport +>>> format_passport("cs 265436") +"CS265436" +>>> format_passport("C-5265436") +None +>>> format_passport("aa123456") +"AA123456" +``` + +### remove_symbols_passport + +Remove símbolos de formatação de uma string de Passaporte. +Remove `"."`, `"-"` e espaços. + +Argumentos: + +- passport (str): A string de Passaporte contendo símbolos a serem removidos. + +Retorna: + +- str: A string de Passaporte limpa com os símbolos removidos. + +Exemplo: + +```python +>>> from brutils import remove_symbols_passport +>>> remove_symbols_passport("CS-265.436") +"CS265436" +``` + +### generate_passport + +Gera uma string de Passaporte sintaticamente válida de forma aleatória. +Cria duas letras maiúsculas seguidas de seis dígitos. +O Passaporte não corresponde a um documento real. + +Retorna: + +- str: Uma string de Passaporte sintaticamente válida gerada aleatoriamente. + +Exemplo: + +```python +>>> from brutils import generate_passport +>>> generate_passport() +"HU546394" +``` # Novos Utilitários e Reportar Bugs diff --git a/README_EN.md b/README_EN.md index 087bf34..08d3ada 100644 --- a/README_EN.md +++ b/README_EN.md @@ -99,6 +99,11 @@ False - [Monetary](#monetary) - [format_currency](#format_currency) - [convert\_real\_to\_text](#convert_real_to_text) +- [Passport](#passport) + - [is_valid_passport](#is_valid_passport) + - [format_passport](#format_passport-1) + - [remove_symbols_passport](#remove_symbols_passport) + - [generate_passport](#generate_passport-1) ## CPF @@ -1317,6 +1322,97 @@ Example: >>> convert_real_to_text("invalid") None ``` +## Passport + +### is_valid_passport + +Validate the format of a Brazilian Passport number. +The input must have exactly two letters followed by six digits. +This function does not check if the Passport actually exists. + +Args: + +- passport (str): A Passport string. + +Returns: + +- bool: True if the format is valid, False otherwise. + +Example: + +```python +>>> from brutils import is_valid_passport +>>> is_valid_passport("CS265436") +True +>>> is_valid_passport("CS-265.436") +False +``` + +### format_passport + +Normalize a Passport string for display. + +This function takes a Passport string (possibly with visual-aid symbols such as spaces, dots, or dashes), removes those symbols if necessary, and returns an uppercase string if the input is valid. + +Args: + +- passport (str): A Passport string with or without symbols. + +Returns: + +- str: An uppercase Passport string without symbols if valid, + `None` if the input is invalid. + +Example: + +```python +>>> from brutils import format_passport +>>> format_passport("cs 265436") +"CS265436" +>>> format_passport("C-5265436") +None +>>> format_passport("aa123456") +"AA123456" +``` + +### remove_symbols_passport + +Remove formatting symbols from a Passport string. +It deletes `"."`, `"-"`, and spaces. + +Args: + +- passport (str): The Passport string containing symbols to be removed. + +Returns: + +- str: A clean Passport string with the specified symbols removed. + +Example: + +```python +>>> from brutils import remove_symbols_passport +>>> remove_symbols_passport("CS-265.436") +"CS265436" +``` + +### generate_passport + +Generate a random syntactically valid Passport string. +It creates two uppercase letters followed by six digits. +The Passport does not correspond to a real document. + +Returns: + +- str: A random syntactically valid Passport string. + +Example: + +```python +>>> from brutils import generate_passport +>>> generate_passport() +"HU546394" +``` # Feature Request and Bug Report diff --git a/brutils/__init__.py b/brutils/__init__.py index e0f75d3..cf41b42 100644 --- a/brutils/__init__.py +++ b/brutils/__init__.py @@ -52,6 +52,20 @@ from brutils.license_plate import is_valid as is_valid_license_plate from brutils.license_plate import remove_symbols as remove_symbols_license_plate +# Passport Imports +from brutils.passport import ( + format_passport, +) +from brutils.passport import ( + generate as generate_passport, +) +from brutils.passport import ( + is_valid as is_valid_passport, +) +from brutils.passport import ( + remove_symbols as remove_symbols_passport, +) + # Phone Imports from brutils.phone import ( format_phone, @@ -131,4 +145,9 @@ # Currency "format_currency", "convert_real_to_text", + # Passport + "format_passport", + "generate_passport", + "is_valid_passport", + "remove_symbols_passport", ] diff --git a/brutils/passport.py b/brutils/passport.py new file mode 100644 index 0000000..b52adcb --- /dev/null +++ b/brutils/passport.py @@ -0,0 +1,189 @@ +from random import randint + +# FORMATTING +############ + + +def sieve(dirty): # type: (str) -> str + """ + Removes specific symbols from a Passport string. + + This function takes a Passport string as input and removes all occurrences of + the '.', '-' and space characters from it. + + Args: + passport (str): The Passport string containing symbols to be removed. + + Returns: + str: A new string with the specified symbols removed. + + Example: + >>> sieve("CS-265.436") + 'CS265436' + >>> sieve("cs 265 436") + 'cs265436' + + .. note:: + This method should not be used in new code and is only provided for + backward compatibility. + """ + + return "".join(filter(lambda char: char not in ".- ", dirty)) + + +def remove_symbols(dirty): # type: (str) -> str + """ + Alias for the `sieve` function. Better naming. + + Args: + passport (str): The Passport string containing symbols to be removed. + + Returns: + str: A new string with the specified symbols removed. + """ + + return sieve(dirty) + + +def display(passport): # type: (str) -> str + """ + Normalize a Passport string for display. + + This function takes a Passport string (possibly with visual-aid symbols) and + returns an uppercase, symbols-free representation if valid. + + Args: + passport (str): A Passport string with or without symbols. + + Returns: + str: An uppercase Passport string without symbols or None if the input + is invalid. + + Example: + >>> display("cs 265.436") + 'CS265436' + >>> display("AB123456") + 'AB123456' + + .. note:: + This method should not be used in new code and is only provided for + backward compatibility. + """ + + if not isinstance(passport, str): + return None + + cleaned = sieve(passport).upper() + if len(cleaned) != 8: + return None + + # Pattern: 2 letters + 6 digits + if not (cleaned[:2].isalpha() and cleaned[2:].isdigit()): + return None + + return cleaned + + +def format_passport(passport): # type: (str) -> str + """ + Normalize a Passport string for display. + + This function takes a Passport string (possibly with visual-aid symbols) and + returns an uppercase, symbols-free representation if valid. + + Args: + passport (str): A Passport string with or without symbols. + + Returns: + str: An uppercase Passport string without symbols or None if the input + is invalid. + + Example: + >>> format_passport("cs 265436") + 'CS265436' + >>> format_passport("C-5265436") + None + """ + if is_valid(passport): + return sieve(passport).upper() + + # Clean common separators and validate again + cleaned = remove_symbols(passport) + if cleaned != passport and is_valid(cleaned): + return cleaned.upper() + + return None + + +# OPERATIONS +############ + + +def validate(passport): # type: (str) -> bool + """ + Validate the format of a Brazilian Passport number. + + This function checks whether the given Passport matches the expected format: + exactly two letters (series) followed by six digits. It does not verify the + existence of a real document. + + Args: + passport (str): A Passport string. + + Returns: + bool: True if the format is valid, False otherwise. + + Example: + >>> validate("CS265436") + True + >>> validate("AA000001") + True + """ + if len(passport) != 8: + return False + return passport[:2].isalpha() and passport[2:].isdigit() + + +def is_valid(passport): # type: (str) -> bool + """ + Returns whether or not the given Passport matches the expected format. + + This function does not verify the existence of the Passport; it only + validates the format of the string (two letters followed by six digits). + + Args: + passport (str): The Passport to be validated. + + Returns: + bool: True if the string matches the required pattern, False otherwise. + + Example: + >>> is_valid("CS265436") + True + >>> is_valid("A123456") + False + """ + return isinstance(passport, str) and validate(passport) + + +def generate(): # type: () -> str + """ + Generate a random syntactically valid Passport string. + + This function generates a random Passport string composed of two uppercase + letters followed by six digits. It does not correspond to a real document. + + Returns: + str: A random syntactically valid Passport string. + + Example: + >>> p = generate() + >>> len(p), p[:2].isalpha(), p[2:].isdigit() + (8, True, True) + """ + + # two uppercase letters (A–Z) + letters = "".join(chr(randint(ord("A"), ord("Z"))) for _ in range(2)) + # six digits (0–9) + digits = "".join(str(randint(0, 9)) for _ in range(6)) + return letters + digits diff --git a/tests/test_passport.py b/tests/test_passport.py new file mode 100644 index 0000000..73ef3ec --- /dev/null +++ b/tests/test_passport.py @@ -0,0 +1,95 @@ +from unittest import TestCase, main +from unittest.mock import patch + +from brutils.passport import ( + display, + format_passport, + generate, + is_valid, + remove_symbols, + sieve, + validate, +) + + +class TestPassport(TestCase): + def test_sieve(self): + self.assertEqual(sieve("CS265436"), "CS265436") + self.assertEqual(sieve("CS-265.436"), "CS265436") + self.assertEqual(sieve("cs 265 436"), "cs265436") + self.assertEqual(sieve("..-- "), "") + self.assertEqual(sieve("ab.CS 265-436*!"), "abCS265436*!") + + def test_display(self): + self.assertEqual(display("cs 265.436"), "CS265436") + self.assertEqual(display("AB123456"), "AB123456") + + # invalid: not str + self.assertIsNone(display(123)) # type: ignore[arg-type] + # invalid: wrong length + self.assertIsNone(display("A123456")) # 7 chars + self.assertIsNone(display("ABC12345")) # 8 chars but 3 letters + self.assertIsNone(display("AB1234567")) # 9 chars + # invalid: wrong pattern + self.assertIsNone(display("A_123456")) # underscore + self.assertIsNone(display("12A34567")) # digit in letter slot + + def test_validate(self): + self.assertIs(validate("CS265436"), True) + self.assertIs(validate("aa000001"), True) + self.assertIs(validate("A123456"), False) + self.assertIs(validate("ABC12345"), False) + self.assertIs(validate("A_123456"), False) + + def test_is_valid(self): + # When passport is not string, returns False + self.assertIs(is_valid(1), False) # type: ignore[arg-type] + + # When length is wrong, returns False + self.assertIs(is_valid("A123456"), False) + + # When characters are invalid, returns False + self.assertIs(is_valid("CS-265.436"), False) + self.assertIs(is_valid("C_265436"), False) + self.assertIs(is_valid("12A34567"), False) + + # When valid + self.assertIs(is_valid("CS265436"), True) + self.assertIs(is_valid("aa000001"), True) + + def test_generate(self): + # multiple generations must be valid & displayable + for _ in range(10_000): + p = generate() + self.assertIs(validate(p), True) + self.assertIsNotNone(display(p)) + + +@patch("brutils.passport.sieve") +class TestRemoveSymbols(TestCase): + def test_remove_symbols_calls_sieve(self, mock_sieve): + # When call remove_symbols, it calls sieve (same pattern as CPF) + remove_symbols("CS-265.436") + mock_sieve.assert_called() + + +@patch("brutils.passport.is_valid") +class TestIsValidToFormat(TestCase): + def test_when_passport_is_valid_returns_formatted(self, mock_is_valid): + mock_is_valid.return_value = True + + # When passport is valid, returns normalized uppercase without symbols + self.assertEqual(format_passport("cs 265436"), "CS265436") + + # Checks if function is_valid is called + mock_is_valid.assert_called_once_with("cs 265436") + + def test_when_passport_is_not_valid_returns_none(self, mock_is_valid): + mock_is_valid.return_value = False + + # When passport isn't valid, returns None + self.assertIsNone(format_passport("cs-26543")) + + +if __name__ == "__main__": + main()