diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d8fce744..86c4c39f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,7 +18,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Generate Report run: | - pip install coverage + pip install coverage num2words coverage run -m unittest discover tests/ - name: Upload Coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9650cc14..39a775ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Utilitário `convert_code_to_uf` [#397](https://github.com/brazilian-utils/brutils-python/pull/410) +- Utilitário `convert_date_to_text`[#394](https://github.com/brazilian-utils/brutils-python/pull/415) - Utilitário `get_municipality_by_code` [412](https://github.com/brazilian-utils/brutils-python/pull/412) ## [2.2.0] - 2024-09-12 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26c4b8da..756e0891 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,7 +98,7 @@ emulate bash -c '. .../bin/activate' Para testar se o ambiente virtual está ativo corretamente, execute o comando e verifique se a resposta é algo parecido com a seguinte: ```sh -$ poetry env inf +$ poetry env info Virtualenv Python: 3.x.y Implementation: CPython diff --git a/README.md b/README.md index be1a52ac..9fefad21 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ False - [generate\_phone](#generate_phone) - [Email](#email) - [is\_valid\_email](#is_valid_email) +- [Data](#date) + - [convert\_date\_to_text](#convert_date_to_text) - [Placa de Carro](#placa-de-carro) - [is\_valid\_license\_plate](#is_valid_license_plate) - [format\_license\_plate](#format_license_plate) @@ -629,6 +631,33 @@ False False ``` +## Data + +## convert_date_to_text + +Converte uma data em sua representação textual. + +Argumentos: + - date (str): Uma string no formato dd/mm/aaaa + +Retorna: + - A represetação textual da data ou None caso a data esteja mal formatada ou a data seja inválida. + +Exemplo: + +````python +>>> from brutils import convert_date_to_text +>>> convert_date_to_text("25/12/2000") +"Vinte e cinco de dezembro de dois mil" +>>> convert_date_to_text("31/02/2000") +None +>>> convert_date_to_text("29/02/2024") +"Vinte e nove de fevereiro de dois mil e vinte e quatro" +>>> convert_date_to_text("1/08/2024") +"Primeiro de agosto de dois mil e vinte e quatro" +```` + + ## Placa de Carro ### is_valid_license_plate diff --git a/README_EN.md b/README_EN.md index 58a24862..fa2037a7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -58,6 +58,8 @@ False - [generate\_cep](#generate_cep) - [get\_address\_from\_cep](#get_address_from_cep) - [get\_cep\_information\_from\_address](#get_cep_information_from_address) +- [Date](#date) + - [convert\_date\_to_text](#convert_date_to_text) - [Phone](#phone) - [is\_valid\_phone](#is_valid_phone) - [format\_phone](#format_phone) @@ -449,6 +451,32 @@ Example: ] ``` +## Date + +### convert_date_to_text +Convert a brazilian date (dd/mm/yyyy) format in their portuguese textual representation. + +Args: + - date (str): A date in a string format dd/mm/yyyy. + +Return: + - (str) | None: A portuguese textual representation of the date or None case a date is invalid. + + +Example: + +````python +>>> from brutils import convert_date_to_text +>>> convert_date_to_text("25/12/2000") +"Vinte e cinco de dezembro de dois mil" +>>> convert_date_to_text("31/02/2000") +None +>>> convert_date_to_text("29/02/2024") +"Vinte e nove de fevereiro de dois mil e vinte e quatro" +>>> convert_date_to_text("1/08/2024") +"Primeiro de agosto de dois mil e vinte e quatro" +```` + ## Phone ### is_valid_phone diff --git a/brutils/__init__.py b/brutils/__init__.py index fc7f35a3..6a7b6d43 100644 --- a/brutils/__init__.py +++ b/brutils/__init__.py @@ -42,6 +42,9 @@ remove_symbols as remove_symbols_cpf, ) +# Date imports +from brutils.date import convert_date_to_text + # Email Import from brutils.email import is_valid as is_valid_email from brutils.ibge.municipality import ( @@ -144,6 +147,8 @@ "generate_cpf", "is_valid_cpf", "remove_symbols_cpf", + # Date + "convert_date_to_text", # Email "is_valid_email", # Legal Process diff --git a/brutils/data/enums/months.py b/brutils/data/enums/months.py new file mode 100644 index 00000000..5e22e56f --- /dev/null +++ b/brutils/data/enums/months.py @@ -0,0 +1,57 @@ +from brutils.data.enums.better_enum import BetterEnum + + +class MonthsEnum(BetterEnum): + JANEIRO = 1 + FEVEREIRO = 2 + MARCO = 3 + ABRIL = 4 + MAIO = 5 + JUNHO = 6 + JULHO = 7 + AGOSTO = 8 + SETEMBRO = 9 + OUTUBRO = 10 + NOVEMBRO = 11 + DEZEMBRO = 12 + + @property + def month_name(self) -> str: + if self == MonthsEnum.JANEIRO: + return "janeiro" + elif self == MonthsEnum.FEVEREIRO: + return "fevereiro" + elif self == MonthsEnum.MARCO: + return "marco" + elif self == MonthsEnum.ABRIL: + return "abril" + elif self == MonthsEnum.MAIO: + return "maio" + elif self == MonthsEnum.JUNHO: + return "junho" + elif self == MonthsEnum.JULHO: + return "julho" + elif self == MonthsEnum.AGOSTO: + return "agosto" + elif self == MonthsEnum.SETEMBRO: + return "setembro" + elif self == MonthsEnum.OUTUBRO: + return "outubro" + elif self == MonthsEnum.NOVEMBRO: + return "novembro" + else: + return "dezembro" + + @classmethod + def is_valid_month(cls, month: int) -> bool: + """ + Checks if the given month value is valid. + Args: + month (int): The month to check. + + Returns: + True if the month is valid, False otherwise. + """ + return ( + True if month in set(month.value for month in MonthsEnum) else False + ) diff --git a/brutils/date.py b/brutils/date.py new file mode 100644 index 00000000..98645017 --- /dev/null +++ b/brutils/date.py @@ -0,0 +1,64 @@ +import re +from typing import Union + +from num2words import num2words + +from brutils.data.enums.months import MonthsEnum + + +def convert_date_to_text(date: str) -> Union[str, None]: + """ + Converts a given date in Brazilian format (dd/mm/yyyy) to its textual representation. + + This function takes a date as a string in the format dd/mm/yyyy and converts it + to a string with the date written out in Brazilian Portuguese, including the full + month name and the year. + + Args: + date (str): The date to be converted into text. Expected format: dd/mm/yyyy. + + Returns: + str or None: A string with the date written out in Brazilian Portuguese, + or None if the date is invalid. + + """ + pattern = re.compile(r"\d{2}/\d{2}/\d{4}") + if not re.match(pattern, date): + raise ValueError( + "Date is not a valid date. Please pass a date in the format dd/mm/yyyy." + ) + + day_str, month_str, year_str = date.split("/") + day = int(day_str) + month = int(month_str) + year = int(year_str) + + if 0 <= day > 31: + return None + + if not MonthsEnum.is_valid_month(month): + return None + + # Leap year. + if MonthsEnum(int(month)) is MonthsEnum.FEVEREIRO: + if (int(year) % 4 == 0 and int(year) % 100 != 0) or ( + int(year) % 400 == 0 + ): + if day > 29: + return None + else: + if day > 28: + return None + + day_string = "Primeiro" if day == 1 else num2words(day, lang="pt") + month = MonthsEnum(month) + year_string = num2words(year, lang="pt") + + date_string = ( + day_string.capitalize() + + " de " + + month.month_name + + " de " + + year_string + ) + return date_string diff --git a/poetry.lock b/poetry.lock index 16594c01..fefb6af4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -84,6 +84,30 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +optional = false +python-versions = "*" +files = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] + +[[package]] +name = "num2words" +version = "0.5.13" +description = "Modules to convert numbers to words. Easily extensible." +optional = false +python-versions = "*" +files = [ + {file = "num2words-0.5.13-py3-none-any.whl", hash = "sha256:39e662c663f0a7e15415431ea68eb3dc711b49e3b776d93403e1da0a219ca4ee"}, + {file = "num2words-0.5.13.tar.gz", hash = "sha256:a3064716fbbf90d75c449450cebfbc73a6a13e63b2531d09bdecc3ab1a2209cf"}, +] + +[package.dependencies] +docopt = ">=0.6.2" + [[package]] name = "ruff" version = "0.6.7" @@ -114,4 +138,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "989d447f5ac999811a4e25ed6b1fc0749aa127fb45290a675b17e652d85b4123" +content-hash = "957675c81621c16701bae337f25dc9571f3c2a787002725627de366f1b01d16a" diff --git a/pyproject.toml b/pyproject.toml index e70382e8..7d967168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.8.1" +num2words = "0.5.13" [tool.poetry.group.test.dependencies] coverage = "^7.2.7" diff --git a/tests/test_date.py b/tests/test_date.py new file mode 100644 index 00000000..3691ec76 --- /dev/null +++ b/tests/test_date.py @@ -0,0 +1,80 @@ +from unittest import TestCase + +from num2words import num2words + +from brutils import convert_date_to_text +from brutils.data.enums.months import MonthsEnum + + +class TestNum2Words(TestCase): + def test_num_conversion(self) -> None: + """ + Smoke test of the num2words library. + This test is used to guarantee that our dependency still works. + """ + self.assertEqual(num2words(30, lang="pt-br"), "trinta") + self.assertEqual(num2words(42, lang="pt-br"), "quarenta e dois") + self.assertEqual( + num2words(2024, lang="pt-br"), "dois mil e vinte e quatro" + ) + self.assertEqual(num2words(0, lang="pt-br"), "zero") + self.assertEqual(num2words(-1, lang="pt-br"), "menos um") + + +class TestDate(TestCase): + def test_convert_date_to_text(self): + self.assertEqual( + convert_date_to_text("15/08/2024"), + "Quinze de agosto de dois mil e vinte e quatro", + ) + self.assertEqual( + convert_date_to_text("01/01/2000"), + "Primeiro de janeiro de dois mil", + ) + self.assertEqual( + convert_date_to_text("31/12/1999"), + "Trinta e um de dezembro de mil novecentos e noventa e nove", + ) + + # + self.assertIsNone(convert_date_to_text("30/02/2020"), None) + self.assertIsNone(convert_date_to_text("30/00/2020"), None) + self.assertIsNone(convert_date_to_text("30/02/2000"), None) + self.assertIsNone(convert_date_to_text("50/09/2000"), None) + self.assertIsNone(convert_date_to_text("25/15/2000"), None) + self.assertIsNone(convert_date_to_text("29/02/2019"), None) + + # Invalid date pattern. + self.assertRaises(ValueError, convert_date_to_text, "Invalid") + self.assertRaises(ValueError, convert_date_to_text, "25/1/2020") + self.assertRaises(ValueError, convert_date_to_text, "1924/08/20") + self.assertRaises(ValueError, convert_date_to_text, "5/09/2020") + + self.assertEqual( + convert_date_to_text("29/02/2020"), + "Vinte e nove de fevereiro de dois mil e vinte", + ) + self.assertEqual( + convert_date_to_text("01/01/1900"), + "Primeiro de janeiro de mil e novecentos", + ) + + months_year = [ + (1, "janeiro"), + (2, "fevereiro"), + (3, "marco"), + (4, "abril"), + (5, "maio"), + (6, "junho"), + (7, "julho"), + (8, "agosto"), + (9, "setembro"), + (10, "outubro"), + (11, "novembro"), + (12, "dezembro"), + ] + + def testMonthEnum(self): + for number_month, name_month in self.months_year: + month = MonthsEnum(number_month) + self.assertEqual(month.month_name, name_month)