├── tests ├── __init__.py ├── ibge │ ├── __init__.py │ └── test_uf.py ├── phone │ ├── __init__.py │ ├── test_phone.py │ └── test_is_valid.py ├── license_plate │ ├── __init__.py │ ├── test_license_plate.py │ └── test_is_valid.py ├── test_cnh.py ├── test_renavam.py ├── test_email.py ├── test_pis.py ├── test_legal_nature.py ├── test_legal_process.py ├── test_cpf.py ├── test_cnpj.py ├── test_imports.py ├── test_voter_id.py ├── test_currency.py ├── test_date_utils.py └── test_cep.py ├── brutils ├── ibge │ ├── __init__.py │ ├── uf.py │ └── municipality.py ├── schemas │ ├── __init__.py │ └── address.py ├── exceptions │ ├── __init__.py │ └── cep.py ├── data │ └── enums │ │ ├── __init__.py │ │ ├── better_enum.py │ │ ├── uf.py │ │ └── months.py ├── email.py ├── renavam.py ├── cnh.py ├── date_utils.py ├── pis.py ├── currency.py ├── __init__.py ├── legal_process.py ├── phone.py ├── legal_nature.py ├── cpf.py ├── license_plate.py ├── cnpj.py └── cep.py ├── .flake8 ├── codecov.yml ├── .github ├── FUNDING.yml ├── CODEOWNERS ├── workflows │ ├── validate-pr-title-v1.yml │ ├── autoassign-issue-v1.yml │ ├── close-stale-issues-v1.yml │ ├── publish-to-production.yml │ ├── run-tests.yml │ └── check-lint.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── nova-funcionalidade-🌟.md │ └── reportar-um-bug-🐛.md └── pull_request_template.md ├── requirements-dev.txt ├── .githooks └── pre-push ├── old_versions_documentation ├── README.md └── v1.0.1 │ ├── ENGLISH_VERSION.md │ └── PORTUGUESE_VERSION.md ├── Makefile ├── LICENSE ├── pyproject.toml ├── CORE_TEAM_EN.md ├── CORE_TEAM.md ├── CODE_OF_CONDUCT_EN.md ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── MAINTAINING.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ibge/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/phone/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /brutils/ibge/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/license_plate/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=80 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "tests" 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [cumbucadev, camilamaia] 2 | -------------------------------------------------------------------------------- /brutils/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .address import Address 2 | -------------------------------------------------------------------------------- /brutils/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from .cep import CEPNotFound, InvalidCEP 2 | -------------------------------------------------------------------------------- /brutils/data/enums/__init__.py: -------------------------------------------------------------------------------- 1 | from brutils.data.enums.uf import UF, UF_CODE 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @brazilian-utils/python-maintainers @brazilian-utils/python-core-team 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | holidays>=0.58 2 | num2words==0.5.13 3 | coverage>=7.2.7 4 | ruff>=0.5.0,<0.7.0 5 | -------------------------------------------------------------------------------- /brutils/exceptions/cep.py: -------------------------------------------------------------------------------- 1 | class InvalidCEP(Exception): 2 | def __init__(self, cep): 3 | self.cep = cep 4 | super().__init__(f"CEP '{cep}' is invalid.") 5 | 6 | 7 | class CEPNotFound(Exception): 8 | pass 9 | -------------------------------------------------------------------------------- /.githooks/pre-push: -------------------------------------------------------------------------------- 1 | printf "Running pre-push hook checks\n\n" 2 | 3 | make check 4 | 5 | bash -c "if [[ $? != 0 ]];then printf '\nPlease run the following command before push:\n\n make format\n\n\ 6 | Remember to commit any changes made on the process.\n'; exit 1; fi" 7 | -------------------------------------------------------------------------------- /brutils/schemas/address.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | 4 | class Address(TypedDict): 5 | cep: str 6 | logradouro: str 7 | complemento: str 8 | bairro: str 9 | localidade: str 10 | uf: str 11 | ibge: str 12 | gia: str 13 | ddd: str 14 | siafi: str 15 | -------------------------------------------------------------------------------- /.github/workflows/validate-pr-title-v1.yml: -------------------------------------------------------------------------------- 1 | name: "Validate PR title" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - reopened 9 | 10 | permissions: 11 | pull-requests: write 12 | 13 | jobs: 14 | lint_pr: 15 | name: Validate 16 | uses: cumbucadev/shared-workflows/.github/workflows/validate-pr-title-v1.yml@main 17 | -------------------------------------------------------------------------------- /brutils/data/enums/better_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, EnumMeta 2 | 3 | 4 | class __MetaEnum(EnumMeta): 5 | @property 6 | def names(cls): 7 | return sorted(cls._member_names_) 8 | 9 | @property 10 | def values(cls): 11 | return sorted(list(map(lambda x: x.value, cls._member_map_.values()))) 12 | 13 | 14 | class BetterEnum(Enum, metaclass=__MetaEnum): 15 | pass 16 | -------------------------------------------------------------------------------- /.github/workflows/autoassign-issue-v1.yml: -------------------------------------------------------------------------------- 1 | name: Autoassign issue 2 | 3 | on: 4 | issue_comment: 5 | types: 6 | - created 7 | 8 | permissions: 9 | contents: read 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | issue_assign: 15 | name: "Call `Autoassign issue` shared workflow" 16 | uses: cumbucadev/shared-workflows/.github/workflows/autoassign-issue-v1.yml@main 17 | -------------------------------------------------------------------------------- /.github/workflows/close-stale-issues-v1.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues and PRs" 2 | 3 | on: 4 | schedule: 5 | - cron: '00 4 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | actions: write 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | stale: 15 | name: Stale 16 | uses: cumbucadev/shared-workflows/.github/workflows/close-stale-issues-v1.yml@main 17 | -------------------------------------------------------------------------------- /tests/test_cnh.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from brutils.cnh import is_valid_cnh 4 | 5 | 6 | class TestCNH(TestCase): 7 | def test_is_valid_cnh(self): 8 | self.assertFalse(is_valid_cnh("22222222222")) 9 | self.assertFalse(is_valid_cnh("ABC70304734")) 10 | self.assertFalse(is_valid_cnh("6619558737912")) 11 | self.assertTrue(is_valid_cnh("097703047-34")) 12 | self.assertTrue(is_valid_cnh("09770304734")) 13 | -------------------------------------------------------------------------------- /old_versions_documentation/README.md: -------------------------------------------------------------------------------- 1 | # Documentação Antiga 2 | 3 | Aqui você vai encontrar a documentação de versões já descontinuadas. 4 | 5 | A documentação das versões que ainda estão sendo mantidas está disponível nos 6 | arquivos README.md/README_EN.md. 7 | 8 | # Old Documentation 9 | 10 | Here you will find documentation for discontinued versions. 11 | 12 | Documentation for versions that are still being maintained is available on 13 | README.md/README_EN.md. 14 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-production.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Production 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build-n-publish: 7 | name: Publish to PyPI. Build and publish Python 🐍 distributions 📦 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repo 11 | uses: actions/checkout@v4 12 | - name: Poetry Setup 13 | uses: snok/install-poetry@v1 14 | - name: Build and publish to pypi 15 | run: | 16 | poetry build 17 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 18 | poetry publish 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: weekly 12 | time: "03:00" 13 | open-pull-requests-limit: 10 14 | allow: 15 | - dependency-type: direct 16 | - dependency-type: indirect 17 | - package-ecosystem: github-actions 18 | directory: / 19 | schedule: 20 | interval: weekly 21 | time: "03:00" 22 | open-pull-requests-limit: 10 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | @git config --local core.hooksPath .githooks/ 3 | # This must be indented like this, otherwise it will not work on Windows 4 | # see: https://stackoverflow.com/questions/77974076/how-do-i-fix-this-error-when-checking-os-in-makefile 5 | ifneq ($(OS),Windows_NT) 6 | @chmod -R +x .githooks 7 | endif 8 | @poetry install 9 | 10 | shell: 11 | @poetry shell 12 | 13 | run-python: 14 | @poetry run python 15 | 16 | format: 17 | @poetry run ruff format . 18 | @poetry run ruff check --fix . 19 | 20 | check: 21 | @poetry run ruff format --check . 22 | @poetry run ruff check . 23 | 24 | test: 25 | ifeq ($(OS),Windows_NT) 26 | @set PYTHONDONTWRITEBYTECODE=1 && poetry run python -m unittest discover tests/ -v 27 | else 28 | @PYTHONDONTWRITEBYTECODE=1 poetry run python3 -m unittest discover tests/ -v 29 | endif -------------------------------------------------------------------------------- /tests/test_renavam.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from brutils.renavam import is_valid_renavam 4 | 5 | 6 | class TestRENAVAM(TestCase): 7 | def test_is_valid_renavam(self): 8 | self.assertTrue(is_valid_renavam("86769597308")) 9 | self.assertFalse(is_valid_renavam("12345678901")) 10 | self.assertFalse(is_valid_renavam("1234567890a")) 11 | self.assertFalse(is_valid_renavam("12345678 901")) 12 | self.assertFalse(is_valid_renavam("12345678")) 13 | self.assertFalse(is_valid_renavam("")) 14 | self.assertFalse(is_valid_renavam("123456789012")) 15 | self.assertFalse(is_valid_renavam("abcdefghijk")) 16 | self.assertFalse(is_valid_renavam("12345678901!")) 17 | self.assertFalse(is_valid_renavam("00000000000")) 18 | self.assertFalse(is_valid_renavam("11111111111")) 19 | -------------------------------------------------------------------------------- /brutils/email.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def is_valid(email: str) -> bool: 5 | """ 6 | Check if a string corresponds to a valid email address. 7 | 8 | Args: 9 | email (str): The input string to be checked. 10 | 11 | Returns: 12 | bool: True if email is a valid email address, False otherwise. 13 | 14 | Example: 15 | >>> is_valid("brutils@brutils.com") 16 | True 17 | >>> is_valid("invalid-email@brutils") 18 | False 19 | 20 | .. note:: 21 | The rules for validating an email address generally follow the 22 | specifications defined by RFC 5322 (updated by RFC 5322bis), 23 | which is the widely accepted standard for email address formats. 24 | """ 25 | 26 | pattern = re.compile( 27 | r"^(?![.])[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" 28 | ) 29 | return isinstance(email, str) and re.match(pattern, email) is not None 30 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.10', '3.11', '3.12', '3.13'] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set Up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Poetry Setup 20 | uses: snok/install-poetry@v1 21 | with: 22 | version: 1.8.4 23 | - name: Install Dependencies 24 | run: poetry install 25 | - name: Generate Report 26 | run: | 27 | poetry run coverage run -m unittest discover -s tests 28 | - name: Upload Coverage to Codecov 29 | uses: codecov/codecov-action@v5 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/check-lint.yml: -------------------------------------------------------------------------------- 1 | name: Check Lint 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | name: Check Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v4 15 | 16 | - name: Poetry Setup 17 | uses: snok/install-poetry@v1 18 | 19 | - name: Install Dependencies 20 | run: poetry install 21 | 22 | - name: Run Lint Check 23 | id: lint 24 | run: | 25 | set -o pipefail 26 | make check 2>&1 | tee lint_output.log 27 | continue-on-error: true 28 | 29 | - name: Lint Failed 30 | if: steps.lint.outcome != 'success' 31 | run: | 32 | echo -e "\033[0;31m Linting failed. See the errors below:\n" 33 | cat lint_output.log 34 | echo -e "\n\033[0;33m Please, run \`make format\` and push the changes to fix this error." 35 | exit 1 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Brazilian Utils 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /brutils/data/enums/uf.py: -------------------------------------------------------------------------------- 1 | from brutils.data.enums.better_enum import BetterEnum 2 | 3 | 4 | class UF(BetterEnum): 5 | AC = "Acre" 6 | AL = "Alagoas" 7 | AP = "Amapá" 8 | AM = "Amazonas" 9 | BA = "Bahia" 10 | CE = "Ceará" 11 | DF = "Distrito Federal" 12 | ES = "Espírito Santo" 13 | GO = "Goiás" 14 | MA = "Maranhão" 15 | MT = "Mato Grosso" 16 | MS = "Mato Grosso do Sul" 17 | MG = "Minas Gerais" 18 | PA = "Pará" 19 | PB = "Paraíba" 20 | PR = "Paraná" 21 | PE = "Pernambuco" 22 | PI = "Piauí" 23 | RJ = "Rio de Janeiro" 24 | RN = "Rio Grande do Norte" 25 | RS = "Rio Grande do Sul" 26 | RO = "Rondônia" 27 | RR = "Roraima" 28 | SC = "Santa Catarina" 29 | SP = "São Paulo" 30 | SE = "Sergipe" 31 | TO = "Tocantins" 32 | 33 | 34 | class UF_CODE(BetterEnum): 35 | AC = "12" 36 | AL = "27" 37 | AP = "16" 38 | AM = "13" 39 | BA = "29" 40 | CE = "23" 41 | DF = "53" 42 | ES = "32" 43 | GO = "52" 44 | MA = "21" 45 | MT = "51" 46 | MS = "50" 47 | MG = "31" 48 | PA = "15" 49 | PB = "25" 50 | PR = "41" 51 | PE = "26" 52 | PI = "22" 53 | RJ = "33" 54 | RN = "24" 55 | RS = "43" 56 | RO = "11" 57 | RR = "14" 58 | SC = "42" 59 | SP = "35" 60 | SE = "28" 61 | TO = "17" 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "brutils" 3 | version = "2.3.0" 4 | description = "Utils library for specific Brazilian businesses" 5 | authors = ["The Brazilian Utils Organization"] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/brazilian-utils/brutils" 9 | keywords = ["cpf", "cnpj", "cep", "document", "validation", "brazil", "brazilian"] 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "License :: OSI Approved :: MIT License", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3 :: Only", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Python :: 3.13", 19 | "Topic :: Office/Business", 20 | "Topic :: Software Development :: Internationalization", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | "Natural Language :: English", 23 | "Natural Language :: Portuguese", 24 | "Natural Language :: Portuguese (Brazilian)" 25 | ] 26 | 27 | [tool.poetry.dependencies] 28 | python = "^3.10" 29 | holidays = "^0.58" 30 | num2words = "0.5.14" 31 | coverage = "^7.2.7" 32 | 33 | [tool.poetry.group.test.dependencies] 34 | coverage = "^7.2.7" 35 | 36 | [tool.poetry.group.dev.dependencies] 37 | ruff = ">=0.5.0,<0.7.0" 38 | 39 | [tool.ruff] 40 | line-length = 80 41 | lint.extend-select = ["I"] 42 | 43 | [tool.ruff.lint.per-file-ignores] 44 | "__init__.py" = ["F401"] 45 | 46 | [build-system] 47 | requires = ["poetry-core"] 48 | build-backend = "poetry.core.masonry.api" 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/nova-funcionalidade-🌟.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Nova Funcionalidade \U0001F31F" 3 | about: Sugestão de uma nova funcionalidade 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Seu pedido de recurso está relacionado a um problema? Por favor, descreva.** 11 | Uma descrição clara e concisa do que é o problema. Por exemplo: "Fico sempre frustrado quando [...]" 12 | 13 | **Descreva a solução que você gostaria** 14 | Uma descrição clara e concisa do que você deseja que aconteça. 15 | 16 | **Descreva alternativas que você considerou** 17 | Uma descrição clara e concisa de quaisquer soluções ou recursos alternativos que você tenha considerado. 18 | 19 | **Contexto adicional** 20 | Adicione qualquer outro contexto ou capturas de tela sobre o pedido de recurso aqui. 21 | 22 | **💌 Quer contribuir, mas não se sente à vontade?** 23 | 24 | Você tem vontade de contribuir, mas não se sente à vontade em abrir issues, PRs ou fazer perguntas publicamente? 25 | 26 | Nós sabemos como pode ser difícil dar o primeiro passo em um espaço aberto. A insegurança, o medo de errar ou até a sensação de “será que minha dúvida é boba?” podem pesar bastante. E tá tudo bem sentir isso. 💜 27 | 28 | Queremos que você saiba que aqui ninguém precisa enfrentar esse caminho sem apoio. Se preferir um espaço mais reservado, você pode mandar um e-mail para cumbucadev@gmail.com e teremos o maior prazer em ajudar. Seja para tirar dúvidas, pedir orientação ou simplesmente ter alguém para conversar sobre como começar. 29 | 30 | O importante é que você saiba: sua participação é muito bem-vinda, e cada contribuição, por menor que pareça, faz uma grande diferença. ✨ 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/reportar-um-bug-🐛.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Reportar um bug \U0001F41B" 3 | about: Reporte um bug, nos ajude a melhorar! 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Descrição do problema** 11 | Uma descrição clara e concisa do que é o erro. 12 | 13 | **Para Reproduzir** 14 | Passos para reproduzir o comportamento: 15 | 1. Chamar o utilitário '...' 16 | 2. Passando o parâmetro '....' 17 | 3. Ver o erro 18 | 19 | **Comportamento esperado** 20 | Uma descrição clara e concisa do que você esperava que acontecesse. 21 | 22 | **Desktop (por favor, forneça as seguintes informações):** 23 | - Sistema Operacional: [por exemplo, iOS] 24 | - Versão do brutils: [por exemplo, 2.0.0] 25 | 26 | **Contexto adicional** 27 | Adicione qualquer outro contexto sobre o problema aqui. 28 | 29 | **💌 Quer contribuir, mas não se sente à vontade?** 30 | 31 | Você tem vontade de contribuir, mas não se sente à vontade em abrir issues, PRs ou fazer perguntas publicamente? 32 | 33 | Nós sabemos como pode ser difícil dar o primeiro passo em um espaço aberto. A insegurança, o medo de errar ou até a sensação de “será que minha dúvida é boba?” podem pesar bastante. E tá tudo bem sentir isso. 💜 34 | 35 | Queremos que você saiba que aqui ninguém precisa enfrentar esse caminho sem apoio. Se preferir um espaço mais reservado, você pode mandar um e-mail para cumbucadev@gmail.com e teremos o maior prazer em ajudar. Seja para tirar dúvidas, pedir orientação ou simplesmente ter alguém para conversar sobre como começar. 36 | 37 | O importante é que você saiba: sua participação é muito bem-vinda, e cada contribuição, por menor que pareça, faz uma grande diferença. ✨ 38 | -------------------------------------------------------------------------------- /brutils/data/enums/months.py: -------------------------------------------------------------------------------- 1 | from brutils.data.enums.better_enum import BetterEnum 2 | 3 | 4 | class MonthsEnum(BetterEnum): 5 | JANEIRO = 1 6 | FEVEREIRO = 2 7 | MARCO = 3 8 | ABRIL = 4 9 | MAIO = 5 10 | JUNHO = 6 11 | JULHO = 7 12 | AGOSTO = 8 13 | SETEMBRO = 9 14 | OUTUBRO = 10 15 | NOVEMBRO = 11 16 | DEZEMBRO = 12 17 | 18 | @property 19 | def month_name(self) -> str: 20 | if self == MonthsEnum.JANEIRO: 21 | return "janeiro" 22 | elif self == MonthsEnum.FEVEREIRO: 23 | return "fevereiro" 24 | elif self == MonthsEnum.MARCO: 25 | return "marco" 26 | elif self == MonthsEnum.ABRIL: 27 | return "abril" 28 | elif self == MonthsEnum.MAIO: 29 | return "maio" 30 | elif self == MonthsEnum.JUNHO: 31 | return "junho" 32 | elif self == MonthsEnum.JULHO: 33 | return "julho" 34 | elif self == MonthsEnum.AGOSTO: 35 | return "agosto" 36 | elif self == MonthsEnum.SETEMBRO: 37 | return "setembro" 38 | elif self == MonthsEnum.OUTUBRO: 39 | return "outubro" 40 | elif self == MonthsEnum.NOVEMBRO: 41 | return "novembro" 42 | else: 43 | return "dezembro" 44 | 45 | @classmethod 46 | def is_valid_month(cls, month: int) -> bool: 47 | """ 48 | Checks if the given month value is valid. 49 | Args: 50 | month (int): The month to check. 51 | 52 | Returns: 53 | True if the month is valid, False otherwise. 54 | """ 55 | return ( 56 | True if month in set(month.value for month in MonthsEnum) else False 57 | ) 58 | -------------------------------------------------------------------------------- /brutils/renavam.py: -------------------------------------------------------------------------------- 1 | RENAVAM_DV_WEIGHTS = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3] 2 | 3 | 4 | def _validate_renavam_format(renavam: str): 5 | if not isinstance(renavam, str): 6 | return False 7 | if len(renavam) != 11 or not renavam.isdigit(): 8 | return False 9 | if len(set(renavam)) == 1: 10 | return False 11 | return True 12 | 13 | 14 | def _sum_weighted_digits(renavam: str) -> int: 15 | base_digits = [int(d) for d in renavam[:-1][::-1]] 16 | return sum(x * y for x, y in zip(base_digits, RENAVAM_DV_WEIGHTS)) 17 | 18 | 19 | def _calculate_renavam_dv(renavam: str): 20 | weighted_sum = _sum_weighted_digits(renavam) 21 | dv = 11 - (weighted_sum % 11) 22 | return 0 if dv >= 10 else dv 23 | 24 | 25 | def is_valid_renavam(renavam: str) -> bool: 26 | """ 27 | Validates the Brazilian vehicle registration number (RENAVAM). 28 | 29 | This function takes a RENAVAM string and checks if it is valid. 30 | A valid RENAVAM consists of exactly 11 digits, with the last digit as 31 | a verification digit calculated from the previous 10 digits. 32 | 33 | Args: 34 | renavam (str): The RENAVAM string to be validated. 35 | 36 | Returns: 37 | bool: True if the RENAVAM is valid, False otherwise. 38 | 39 | Example: 40 | >>> is_valid_renavam('86769597308') 41 | True 42 | >>> is_valid_renavam('12345678901') 43 | False 44 | >>> is_valid_renavam('1234567890a') 45 | False 46 | >>> is_valid_renavam('12345678 901') 47 | False 48 | >>> is_valid_renavam('12345678') 49 | False 50 | >>> is_valid_renavam('') 51 | False 52 | """ 53 | if not _validate_renavam_format(renavam): 54 | return False 55 | 56 | return _calculate_renavam_dv(renavam) == int(renavam[-1]) 57 | -------------------------------------------------------------------------------- /old_versions_documentation/v1.0.1/ENGLISH_VERSION.md: -------------------------------------------------------------------------------- 1 | # brutils 2 | 3 | ### [Procurando pela versão em português?](PORTUGUESE_VERSION.md) 4 | 5 | _Compatible with Python 2.7 and 3.x_ 6 | 7 | `brutils` is a library for validating brazilian document numbers, and might 8 | eventually evolve to deal with other validations related to brazilian bureaucracy. 9 | 10 | It's main functionality is the validation of CPF and CNPJ numbers, but suggestions 11 | for other (preferrably deterministic) things to validate are welcome. 12 | 13 | 14 | ## Installation 15 | 16 | ``` 17 | pip install brutils 18 | ``` 19 | 20 | 21 | ## Utilization 22 | 23 | ### Importing: 24 | ``` 25 | >>> from brutils import cpf, cnpj 26 | ``` 27 | 28 | ### How do I validate a CPF or CNPJ? 29 | ``` 30 | # numbers only, formatted as strings 31 | 32 | >>> cpf.validate('00011122233') 33 | False 34 | >>> cnpj.validate('00111222000133') 35 | False 36 | ``` 37 | 38 | ### What if my string has formatting symbols in it? 39 | ``` 40 | >>> cpf.sieve('000.111.222-33') 41 | '00011122233' 42 | >>> cnpj.sieve('00.111.222/0001-00') 43 | '00111222000100' 44 | 45 | # The `sieve` function only filters out the symbols used for CPF or CNPJ validation. 46 | # It purposefully doesn't remove other symbols, as those may be indicators of data 47 | # corruption, or a possible lack of input filters. 48 | ``` 49 | 50 | ### What if I want to format a numbers only string? 51 | ``` 52 | >>> cpf.display('00011122233') 53 | '000.111.222-33' 54 | >>> cnpj.display('00111222000100') 55 | '00.111.222/0001-00' 56 | ``` 57 | 58 | ### What if I want to generate random, but numerically valid CPF or CNPJ numbers? 59 | ``` 60 | >>> cpf.generate() 61 | '17433964657' 62 | >>> cnpj.generate() 63 | '34665388000161' 64 | ``` 65 | 66 | 67 | ## Testing 68 | 69 | ``` 70 | python2.7 -m unittest discover tests/ 71 | python3 -m unittest discover tests/ 72 | ``` 73 | -------------------------------------------------------------------------------- /tests/test_email.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | 3 | from brutils import is_valid_email 4 | 5 | 6 | class TestEmailValidation(TestCase): 7 | def test_valid_email(self): 8 | # Valid email addresses 9 | valid_emails = [ 10 | "joao.ninguem@gmail.com", 11 | "user123@gmail.com", 12 | "test.email@mydomain.co.uk", 13 | "johndoe@sub.domain.example", 14 | "f99999999@place.university-campus.ac.in", 15 | ] 16 | for email in valid_emails: 17 | try: 18 | self.assertTrue(is_valid_email(email)) 19 | except: # noqa: E722 20 | print(f"AssertionError for email: {email}") 21 | raise AssertionError 22 | 23 | def test_invalid_email(self): 24 | # Invalid email addresses 25 | invalid_emails = [ 26 | ".joao.ninguem@gmail.com", 27 | "joao ninguem@gmail.com", 28 | "not_an_email", 29 | "@missing_username.com", 30 | "user@incomplete.", 31 | "user@.incomplete", 32 | "user@inva!id.com", 33 | "user@missing-tld.", 34 | ] 35 | for email in invalid_emails: 36 | try: 37 | self.assertFalse(is_valid_email(email)) 38 | except: # noqa: E722 39 | print(f"AssertionError for email: {email}") 40 | raise AssertionError 41 | 42 | def test_non_string_input(self): 43 | # Non-string input should return False 44 | non_strings = [None, 123, True, ["test@example.com"]] 45 | for value in non_strings: 46 | self.assertFalse(is_valid_email(value)) 47 | 48 | def test_empty_string(self): 49 | # Empty string should return False 50 | self.assertFalse(is_valid_email("")) 51 | 52 | 53 | if __name__ == "__main__": 54 | main() 55 | -------------------------------------------------------------------------------- /old_versions_documentation/v1.0.1/PORTUGUESE_VERSION.md: -------------------------------------------------------------------------------- 1 | # brutils 2 | 3 | ### [Looking for the english version?](ENGLISH_VERSION.md) 4 | 5 | _Compatível com Python 2.7 e 3.x_ 6 | 7 | `brutils` é uma biblioteca para tratar de validações de documentos brasileiros, 8 | e que eventualmente pode evoluir para tratar de outras coisas dentro do escopo 9 | de validações relacionadas a burocracias brasileiras. 10 | 11 | Sua principal funcionalidade é a validação de CPFs e CNPJs, mas sugestões sobre 12 | outras coisas a se validar (preferencialmente de maneira determinística) são bem 13 | vindas. 14 | 15 | 16 | ## Instalação 17 | 18 | ``` 19 | pip install brutils 20 | ``` 21 | 22 | 23 | ## Utilização 24 | 25 | ### Importando a Biblioteca: 26 | ``` 27 | >>> from brutils import cpf, cnpj 28 | ``` 29 | 30 | ### Como faço para validar um CPF ou CNPJ? 31 | ``` 32 | # somente numeros, em formato string 33 | 34 | >>> cpf.validate('00011122233') 35 | False 36 | >>> cnpj.validate('00111222000133') 37 | False 38 | ``` 39 | 40 | ### E se a minha string estiver formatada com simbolos? 41 | ``` 42 | >>> cpf.sieve('000.111.222-33') 43 | '00011122233' 44 | >>> cnpj.sieve('00.111.222/0001-00') 45 | '00111222000100' 46 | 47 | # A função `sieve` limpa apenas os simbolos de formatação de CPF ou CNPJ, e de 48 | # whitespace nas pontas. Ela não remove outros caractéres propositalmente, pois 49 | # estes seriam indicativos de uma possível corrupção no dado ou de uma falta de 50 | # filtros de input. 51 | ``` 52 | 53 | ### E se eu quiser formatar uma string numérica? 54 | ``` 55 | >>> cpf.display('00011122233') 56 | '000.111.222-33' 57 | >>> cnpj.display('00111222000100') 58 | '00.111.222/0001-00' 59 | ``` 60 | 61 | ### E se eu quiser gerar CPFs ou CNPJs validos aleatórios? 62 | ``` 63 | >>> cpf.generate() 64 | '17433964657' 65 | >>> cnpj.generate() 66 | '34665388000161' 67 | ``` 68 | 69 | 70 | ## Testes 71 | 72 | ``` 73 | python2.7 -m unittest discover tests/ 74 | python3 -m unittest discover tests/ 75 | ``` 76 | -------------------------------------------------------------------------------- /CORE_TEAM_EN.md: -------------------------------------------------------------------------------- 1 |
2 |

🇧🇷 Core Team brutils-python

3 |
4 | 5 | The _Core Team_ of `brazilian-utils/brutils-python` is comprised of a group of contributors who have demonstrated remarkable enthusiasm for the project and the community. This team holds administrative privileges on GitHub specific to this repository. 6 | 7 | ## Responsibilities 8 | 9 | The _Core Team_ of `brutils-python` has the following responsibilities: 10 | 11 | * Be available to address strategic inquiries regarding the vision and future prospects of Brazilian Utils Python. 12 | 13 | * Commit to reviewing pull requests that have been submitted for some time or have been overlooked. 14 | 15 | * Periodically inspect open issues in Brazilian Utils Python, provide constructive suggestions, and categorize them using specific labels on GitHub. 16 | 17 | * Identify promising individuals in the Brazilian Utils Python community who may express interest in joining the team and contributing significantly. 18 | 19 | Similar to all contributors at `brutils-python`, members of the Core Team also operate as volunteers in the open-source realm; being part of the team is not an obligation. This team is recognized as leaders in this community, and while they are a reliable reference for obtaining answers to questions, it's important to note that they contribute voluntarily, dedicating their time, which may result in non-immediate availability. 20 | 21 | ## Members 22 | 23 | - [@camilamaia](https://github.com/camilamaia) 24 | - [@antoniamaia](https://github.com/antoniamaia) 25 | 26 | ## Adding New Members 27 | 28 | The process for incorporating new members into the _Core Team_ `brutils-python` is as follows: 29 | 30 | * An existing team member privately contacts the individual to gauge their interest. If there is interest, a pull request is opened, adding the new member to the list. 31 | 32 | * Other team members review the pull request. The person responsible for merging the PR is also tasked with adding the new member to the Core Team group on GitHub. 33 | -------------------------------------------------------------------------------- /CORE_TEAM.md: -------------------------------------------------------------------------------- 1 |
2 |

🇧🇷 Core Team brutils-python

3 |
4 | 5 | O _Core Team_ do `brazilian-utils/brutils-python` é composto por um conjunto de colaboradores que manifestaram um entusiasmo notável pelo projeto e pela comunidade. Essa equipe possui privilégios administrativos no GitHub específicos para o repositório. 6 | 7 | ## Responsabilidades 8 | 9 | O _Core Team_ do `brutils-python` possui as seguintes responsabilidades: 10 | 11 | * Estar prontamente disponível para abordar questionamentos de natureza estratégica acerca da visão e perspectivas futuras do Brazilian Utils Python. 12 | 13 | * Comprometer-se a revisar pull requests que tenham sido submetidos há algum tempo ou que tenham sido negligenciados. 14 | 15 | * Periodicamente examinar as questões abertas no Brazilian Utils Python, fornecer sugestões construtivas e categorizá-las utilizando rótulos específicos no GitHub. 16 | 17 | * Identificar indivíduos promissores na comunidade do Brazilian Utils Python que possam expressar interesse em integrar a equipe e contribuir de maneira significativa. 18 | 19 | Da mesma forma que todos os colaboradores do `brutils-python`, os membros do Core Team também atuam como voluntários no âmbito de código aberto; fazer parte da equipe não é uma obrigação. Essa equipe é reconhecida como líder nesta comunidade e, embora seja uma referência confiável para obter respostas a perguntas, é importante salientar que eles contribuem de forma voluntária, dedicando seu tempo, o que pode resultar em disponibilidade não imediata. 20 | 21 | 22 | ## Membros 23 | 24 | - [@camilamaia](https://github.com/camilamaia) 25 | - [@antoniamaia](https://github.com/antoniamaia) 26 | 27 | 28 | ## Adição de Novos Membros 29 | 30 | O procedimento para incorporar novos membros ao _Core Team_ `brutils-python` é o seguinte: 31 | 32 | * Um integrante já existente da equipe entra em contato de forma privada para averiguar o interesse da pessoa. Se houver interesse, é aberto um pull request adicionando o novo membro à lista. 33 | 34 | * Os outros integrantes da equipe revisam o pull request. A pessoa responsável por efetuar o merge no PR é também encarregada de incluir o novo membro no grupo Core Team no GitHub. 35 | 36 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Descrição 2 | 3 | 4 | ## Mudanças Propostas 5 | 10 | 11 | ## Checklist de Revisão 12 | 13 | 14 | - [ ] Eu li o [Contributing.md](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md) 15 | - [ ] Os testes foram adicionados ou atualizados para refletir as mudanças (se aplicável). 16 | - [ ] Foi adicionada uma entrada no changelog / Meu PR não necessita de uma nova entrada no changelog. 17 | - [ ] A [documentação](https://github.com/brazilian-utils/brutils-python/blob/main/README.md) em português foi atualizada ou criada, se necessário. 18 | - [ ] Se feita a documentação, a atualização do [arquivo em inglês](https://github.com/brazilian-utils/brutils-python/blob/main/README_EN.md). 19 | - [ ] Eu documentei as minhas mudanças no código, adicionando docstrings e comentários. [Instruções](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#8-fa%C3%A7a-as-suas-altera%C3%A7%C3%B5es) 20 | - [ ] O código segue as diretrizes de estilo e padrões de codificação do projeto. 21 | - [ ] Todos os testes passam. [Instruções](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#testes) 22 | - [ ] O Pull Request foi testado localmente. [Instruções](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#7-execute-o-brutils-localmente) 23 | - [ ] Não há conflitos de mesclagem. 24 | 25 | ## Comentários Adicionais (opcional) 26 | 27 | 28 | ## Issue Relacionada 29 | 30 | 31 | Closes # 32 | -------------------------------------------------------------------------------- /brutils/cnh.py: -------------------------------------------------------------------------------- 1 | def is_valid_cnh(cnh: str) -> bool: 2 | """ 3 | Validates the registration number for the Brazilian CNH (Carteira Nacional de Habilitação) that was created in 2022. 4 | Previous versions of the CNH are not supported in this version. 5 | This function checks if the given CNH is valid based on the format and allowed characters, 6 | verifying the verification digits. 7 | 8 | Args: 9 | cnh (str): CNH string (symbols will be ignored). 10 | 11 | Returns: 12 | bool: True if CNH has a valid format. 13 | 14 | Examples: 15 | >>> is_valid_cnh("12345678901") 16 | False 17 | >>> is_valid_cnh("A2C45678901") 18 | False 19 | >>> is_valid_cnh("98765432100") 20 | True 21 | >>> is_valid_cnh("987654321-00") 22 | True 23 | """ 24 | cnh = "".join( 25 | filter(str.isdigit, cnh) 26 | ) # clean the input and check for numbers only 27 | 28 | if not cnh: 29 | return False 30 | 31 | if len(cnh) != 11: 32 | return False 33 | 34 | # Reject sequences as "00000000000", "11111111111", etc. 35 | if cnh == cnh[0] * 11: 36 | return False 37 | 38 | # cast digits to list of integers 39 | digits: list[int] = [int(ch) for ch in cnh] 40 | first_verificator = digits[9] 41 | second_verificator = digits[10] 42 | 43 | if not _check_first_verificator( 44 | digits, first_verificator 45 | ): # checking the 10th digit 46 | return False 47 | 48 | return _check_second_verificator( 49 | digits, second_verificator, first_verificator 50 | ) # checking the 11th digit 51 | 52 | 53 | def _check_first_verificator(digits: list[int], first_verificator: int) -> bool: 54 | """ 55 | Generates the first verification digit and uses it to verify the 10th digit of the CNH 56 | """ 57 | 58 | sum = 0 59 | for i in range(9): 60 | sum += digits[i] * (9 - i) 61 | 62 | sum = sum % 11 63 | result = 0 if sum > 9 else sum 64 | 65 | return result == first_verificator 66 | 67 | 68 | def _check_second_verificator( 69 | digits: list[int], second_verificator: int, first_verificator: int 70 | ) -> bool: 71 | """ 72 | Generates the second verification and uses it to verify the 11th digit of the CNH 73 | """ 74 | sum = 0 75 | for i in range(9): 76 | sum += digits[i] * (i + 1) 77 | 78 | result = sum % 11 79 | 80 | if first_verificator > 9: 81 | result = result + 9 if (result - 2) < 0 else result - 2 82 | 83 | if result > 9: 84 | result = 0 85 | 86 | return result == second_verificator 87 | -------------------------------------------------------------------------------- /tests/test_pis.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from unittest.mock import patch 3 | 4 | from brutils.pis import ( 5 | _checksum, 6 | format_pis, 7 | generate, 8 | is_valid, 9 | remove_symbols, 10 | ) 11 | 12 | 13 | class TestPIS(TestCase): 14 | def test_is_valid(self): 15 | # When PIS is not string, returns False 16 | self.assertIs(is_valid(1), False) 17 | self.assertIs(is_valid([]), False) 18 | self.assertIs(is_valid({}), False) 19 | self.assertIs(is_valid(None), False) 20 | 21 | # When PIS's len is different of 11, returns False 22 | self.assertIs(is_valid("123456789"), False) 23 | 24 | # When PIS does not contain only digits, returns False 25 | self.assertIs(is_valid("123pis"), False) 26 | self.assertIs(is_valid("123456789ab"), False) 27 | 28 | # When checksum digit doesn't match last digit, returns False 29 | self.assertIs(is_valid("11111111111"), False) 30 | self.assertIs(is_valid("11111111215"), False) 31 | self.assertIs(is_valid("12038619493"), False) 32 | 33 | # When PIS is valid 34 | self.assertIs(is_valid("12038619494"), True) 35 | self.assertIs(is_valid("12016784018"), True) 36 | self.assertIs(is_valid("12083210826"), True) 37 | 38 | def test_checksum(self): 39 | # Checksum digit is 0 when the subtracted number is 10 or 11 40 | self.assertEqual(_checksum("1204152015"), 0) 41 | self.assertEqual(_checksum("1204433157"), 0) 42 | 43 | # Checksum digit is equal the subtracted number 44 | self.assertEqual(_checksum("1204917738"), 2) 45 | self.assertEqual(_checksum("1203861949"), 4) 46 | self.assertEqual(_checksum("1208321082"), 6) 47 | 48 | def test_generate(self): 49 | for _ in range(10_000): 50 | self.assertIs(is_valid(generate()), True) 51 | 52 | def test_remove_symbols(self): 53 | self.assertEqual(remove_symbols("00000000000"), "00000000000") 54 | self.assertEqual(remove_symbols("170.33259.50-4"), "17033259504") 55 | self.assertEqual(remove_symbols("134..2435/.-1892.-"), "1342435/1892") 56 | self.assertEqual(remove_symbols("abc1230916*!*&#"), "abc1230916*!*&#") 57 | self.assertEqual(remove_symbols("...---..."), "") 58 | 59 | @patch("brutils.pis.is_valid") 60 | def test_format_valid_pis(self, mock_is_valid): 61 | mock_is_valid.return_value = True 62 | 63 | # When PIS is_valid, returns formatted PIS 64 | self.assertEqual(format_pis("14372195539"), "143.72195.53-9") 65 | 66 | # Checks if function is_valid_pis is called 67 | mock_is_valid.assert_called_once_with("14372195539") 68 | 69 | @patch("brutils.pis.is_valid") 70 | def test_format_invalid_pis(self, mock_is_valid): 71 | mock_is_valid.return_value = False 72 | 73 | # When PIS isn't valid, returns None 74 | self.assertIsNone(format_pis("14372195539")) 75 | 76 | 77 | if __name__ == "__main__": 78 | main() 79 | -------------------------------------------------------------------------------- /tests/test_legal_nature.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | 3 | from brutils.legal_nature import ( 4 | LEGAL_NATURE, 5 | get_description, 6 | is_valid, 7 | list_all, 8 | ) 9 | 10 | 11 | class TestNaturezaJuridica(TestCase): 12 | def test_is_valid_non_string(self): 13 | # When input is not string, returns False 14 | self.assertIs(is_valid(1234), False) 15 | self.assertIs(is_valid(None), False) 16 | self.assertIs(is_valid([]), False) 17 | self.assertIs(is_valid({}), False) 18 | 19 | def test_is_valid_invalid_patterns(self): 20 | # Wrong length after normalization or contains no digits 21 | self.assertIs(is_valid(""), False) 22 | self.assertIs(is_valid("20"), False) 23 | self.assertIs(is_valid("20623"), False) 24 | self.assertIs(is_valid("abcd"), False) 25 | self.assertIs(is_valid("---"), False) 26 | 27 | def test_is_valid_formats_hyphen_and_plain(self): 28 | # Accept both "NNNN" and "NNN-N" formats 29 | self.assertTrue(is_valid("2062")) 30 | self.assertTrue(is_valid("206-2")) 31 | self.assertTrue(is_valid("101-5")) 32 | self.assertTrue(is_valid("1015")) 33 | 34 | def test_is_valid_known_codes_true(self): 35 | # A few known valid codes from different sections 36 | for code in ( 37 | "1015", 38 | "2062", 39 | "2143", 40 | "2305", 41 | "3034", 42 | "3131", 43 | "3212", 44 | "4014", 45 | "5002", 46 | ): 47 | self.assertTrue(is_valid(code)) 48 | 49 | def test_get_description_known(self): 50 | self.assertEqual( 51 | get_description("2062"), "Sociedade Empresária Limitada" 52 | ) 53 | self.assertEqual( 54 | get_description("101-5"), "Órgão Público do Poder Executivo Federal" 55 | ) 56 | self.assertEqual(get_description("2143"), "Cooperativa") 57 | self.assertEqual( 58 | get_description("5002"), 59 | "Organização Internacional e Outras Instituições Extraterritoriais", 60 | ) 61 | 62 | def test_get_description_invalid(self): 63 | self.assertIsNone(get_description("9999")) 64 | self.assertIsNone(get_description("0000")) 65 | self.assertIsNone(get_description("20A2")) 66 | self.assertIsNone(get_description(None)) # type: ignore[arg-type] 67 | 68 | def test_table_integrity(self): 69 | # Dictionary should contain only 4-digit string keys and string descriptions 70 | for k, v in LEGAL_NATURE.items(): 71 | self.assertIsInstance(k, str) 72 | self.assertTrue( 73 | k.isdigit() and len(k) == 4, msg=f"Invalid key: {k}" 74 | ) 75 | self.assertIsInstance(v, str) 76 | self.assertTrue(len(v) > 0) 77 | 78 | def test_list_all_returns_copy(self): 79 | data = list_all() 80 | self.assertEqual(data["2062"], "Sociedade Empresária Limitada") 81 | data["2062"] = "X" 82 | self.assertEqual(LEGAL_NATURE["2062"], "Sociedade Empresária Limitada") 83 | 84 | 85 | if __name__ == "__main__": 86 | main() 87 | -------------------------------------------------------------------------------- /tests/test_legal_process.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest import TestCase, main 3 | 4 | from brutils.legal_process import ( 5 | _checksum, 6 | format_legal_process, 7 | generate, 8 | is_valid, 9 | remove_symbols, 10 | ) 11 | 12 | 13 | class TestLegalProcess(TestCase): 14 | def test_format_legal_process(self): 15 | self.assertEqual( 16 | format_legal_process("23141945820055070079"), 17 | ("2314194-58.2005.5.07.0079"), 18 | ) 19 | self.assertEqual( 20 | format_legal_process("00000000000000000000"), 21 | ("0000000-00.0000.0.00.0000"), 22 | ) 23 | self.assertIsInstance( 24 | format_legal_process("00000000000000000000"), 25 | str, 26 | ) 27 | self.assertIsNone(format_legal_process("2314194582005507")) 28 | self.assertIsNone(format_legal_process("0000000000000000000000000")) 29 | self.assertIsNone(format_legal_process("0000000000000000000asdasd")) 30 | 31 | def test_remove_symbols(self): 32 | self.assertEqual( 33 | remove_symbols("6439067-89.2023.4.04.5902"), "64390678920234045902" 34 | ) 35 | self.assertEqual( 36 | remove_symbols("4976023-82.2012.7.00.2263"), "49760238220127002263" 37 | ) 38 | self.assertEqual( 39 | remove_symbols("4976...-02382-.-2012.-7002--263"), 40 | "49760238220127002263", 41 | ) 42 | self.assertEqual( 43 | remove_symbols("4976023-82.2012.7.00.2263*!*&#"), 44 | "49760238220127002263*!*&#", 45 | ) 46 | self.assertEqual( 47 | remove_symbols("4976..#.-0@2382-.#-2012.#-7002--263@"), 48 | "4976#0@2382#2012#7002263@", 49 | ) 50 | self.assertEqual(remove_symbols("@...---...#"), "@#") 51 | self.assertEqual(remove_symbols("...---..."), "") 52 | self.assertIsInstance(remove_symbols("...---..."), str) 53 | 54 | def test_generate(self): 55 | self.assertEqual(generate()[9:13], str(datetime.now().year)) 56 | self.assertEqual(generate(year=3000)[9:13], "3000") 57 | self.assertEqual(generate(orgao=4)[13:14], "4") 58 | self.assertEqual(generate(year=3000, orgao=4)[9:13], "3000") 59 | self.assertIsInstance(generate(year=3000, orgao=4)[9:13], str) 60 | self.assertIsNone(generate(year=1000, orgao=4)) 61 | self.assertIsNone(generate(orgao=0)) 62 | 63 | def test_check_sum(self): 64 | self.assertEqual(_checksum(546611720238150014), "77") 65 | self.assertEqual(_checksum(403818720238230498), "50") 66 | self.assertIsInstance(_checksum(403818720238230498), str) 67 | 68 | def test_is_valid(self): 69 | self.assertIs(is_valid("10188748220234018200"), True) 70 | self.assertIs(is_valid("45532346920234025107"), True) 71 | self.assertIs(is_valid("10188748220239918200"), False) 72 | self.assertIs(is_valid("00000000000000000000"), False) 73 | self.assertIs(is_valid("455323469202340251"), False) 74 | self.assertIs(is_valid("455323469202340257123123123"), False) 75 | self.assertIs(is_valid("455323423QQWEQWSsasd&*(()"), False) 76 | self.assertIsInstance(is_valid("455323423QQWEQWSsasd&*(()"), bool) 77 | 78 | 79 | if __name__ == "__main__": 80 | main() 81 | -------------------------------------------------------------------------------- /brutils/date_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | import holidays 5 | from num2words import num2words 6 | 7 | from brutils.data.enums.months import MonthsEnum 8 | 9 | DATE_REGEX = re.compile(r"^\d{2}/\d{2}/\d{4}$") 10 | 11 | 12 | def is_holiday(target_date: datetime, uf: str = None) -> bool | None: 13 | """ 14 | Checks if the given date is a national or state holiday in Brazil. 15 | 16 | This function takes a date as a `datetime` object and an optional UF (Unidade Federativa), 17 | returning a boolean value indicating whether the date is a holiday or `None` if the date or 18 | UF are invalid. 19 | 20 | The method does not handle municipal holidays. 21 | 22 | Args: 23 | target_date (datetime): The date to be checked. 24 | uf (str, optional): The state abbreviation (UF) to check for state holidays. 25 | If not provided, only national holidays will be considered. 26 | 27 | Returns: 28 | bool | None: Returns `True` if the date is a holiday, `False` if it is not, 29 | or `None` if the date or UF are invalid. 30 | 31 | Note: 32 | The function logic should be implemented using the `holidays` library. 33 | For more information, refer to the documentation at: https://pypi.org/project/holidays/ 34 | 35 | Usage Examples: 36 | >>> from datetime import datetime 37 | >>> is_holiday(datetime(2024, 1, 1)) 38 | True 39 | 40 | >>> is_holiday(datetime(2024, 1, 2)) 41 | False 42 | 43 | >>> is_holiday(datetime(2024, 3, 2), uf="SP") 44 | False 45 | 46 | >>> is_holiday(datetime(2024, 12, 25), uf="RJ") 47 | True 48 | """ 49 | if not isinstance(target_date, datetime): 50 | return None 51 | 52 | national_holidays = holidays.Brazil(years=target_date.year) 53 | valid_ufs = national_holidays.subdivisions 54 | 55 | if uf is not None and uf not in valid_ufs: 56 | return None 57 | 58 | if uf is None: 59 | return target_date in national_holidays 60 | 61 | state_holidays = holidays.Brazil(prov=uf, years=target_date.year) 62 | return target_date in state_holidays 63 | 64 | 65 | def convert_date_to_text(date: str) -> str | None: 66 | """ 67 | Converts a given date in Brazilian format (dd/mm/yyyy) to its textual representation. 68 | 69 | This function takes a date as a string in the format dd/mm/yyyy and converts it 70 | to a string with the date written out in Brazilian Portuguese, including the full 71 | month name and the year. 72 | 73 | Args: 74 | date (str): The date to be converted into text. Expected format: dd/mm/yyyy. 75 | 76 | Returns: 77 | str | None: A string with the date written out in Brazilian Portuguese, 78 | or None if the date is invalid. 79 | 80 | """ 81 | if not DATE_REGEX.match(date): 82 | return None 83 | 84 | try: 85 | dt = datetime.strptime(date, "%d/%m/%Y") 86 | except ValueError: 87 | return None 88 | 89 | day, month, year = dt.day, dt.month, dt.year 90 | 91 | day_str = "Primeiro" if day == 1 else num2words(day, lang="pt") 92 | month_enum = MonthsEnum(month) 93 | year_str = num2words(year, lang="pt") 94 | 95 | return f"{day_str.capitalize()} de {month_enum.month_name} de {year_str}" 96 | -------------------------------------------------------------------------------- /brutils/pis.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | WEIGHTS = [3, 2, 9, 8, 7, 6, 5, 4, 3, 2] 4 | 5 | # FORMATTING 6 | ############ 7 | 8 | 9 | def remove_symbols(pis: str) -> str: 10 | """ 11 | Remove formatting symbols from a PIS. 12 | 13 | This function takes a PIS (Programa de Integração Social) string with 14 | formatting symbols and returns a cleaned version with no symbols. 15 | 16 | Args: 17 | pis (str): A PIS string that may contain formatting symbols. 18 | 19 | Returns: 20 | str: A cleaned PIS string with no formatting symbols. 21 | 22 | Example: 23 | >>> remove_symbols("123.456.789-09") 24 | '12345678909' 25 | >>> remove_symbols("98765432100") 26 | '98765432100' 27 | """ 28 | return pis.replace(".", "").replace("-", "") 29 | 30 | 31 | def format_pis(pis: str) -> str: 32 | """ 33 | Format a valid PIS (Programa de Integração Social) string with 34 | standard visual aid symbols. 35 | 36 | This function takes a valid numbers-only PIS string as input 37 | and adds standard formatting visual aid symbols for display. 38 | 39 | Args: 40 | pis (str): A valid numbers-only PIS string. 41 | 42 | Returns: 43 | str: A formatted PIS string with standard visual aid symbols 44 | or None if the input is invalid. 45 | 46 | Example: 47 | >>> format_pis("12345678909") 48 | '123.45678.90-9' 49 | >>> format_pis("98765432100") 50 | '987.65432.10-0' 51 | """ 52 | 53 | if not is_valid(pis): 54 | return None 55 | 56 | return "{}.{}.{}-{}".format(pis[:3], pis[3:8], pis[8:10], pis[10:11]) 57 | 58 | 59 | # OPERATIONS 60 | ############ 61 | 62 | 63 | def is_valid(pis: str) -> bool: 64 | """ 65 | Returns whether or not the verifying checksum digit of the 66 | given `PIS` match its base number. 67 | 68 | Args: 69 | pis (str): PIS number as a string of proper length. 70 | 71 | Returns: 72 | bool: True if PIS is valid, False otherwise. 73 | 74 | Example: 75 | >>> is_valid_pis("82178537464") 76 | True 77 | >>> is_valid_pis("55550207753") 78 | True 79 | 80 | """ 81 | 82 | return ( 83 | isinstance(pis, str) 84 | and len(pis) == 11 85 | and pis.isdigit() 86 | and pis[-1] == str(_checksum(pis[:-1])) 87 | ) 88 | 89 | 90 | def generate() -> str: 91 | """ 92 | Generate a random valid Brazilian PIS number. 93 | 94 | This function generates a random PIS number with the following characteristics: 95 | - It has 11 digits 96 | - It passes the weight calculation check 97 | 98 | Args: 99 | None 100 | 101 | Returns: 102 | str: A randomly generated valid PIS number as a string. 103 | 104 | Example: 105 | >>> generate() 106 | '12345678909' 107 | >>> generate() 108 | '98765432100' 109 | """ 110 | base = str(randint(0, 9999999999)).zfill(10) 111 | 112 | return base + str(_checksum(base)) 113 | 114 | 115 | def _checksum(base_pis: str) -> int: 116 | """ 117 | Calculate the checksum digit of the given `base_pis` string. 118 | 119 | Args: 120 | base_pis (str): The first 10 digits of a PIS number as a string. 121 | 122 | Returns: 123 | int: The checksum digit. 124 | """ 125 | pis_digits = list(map(int, base_pis)) 126 | pis_sum = sum(digit * weight for digit, weight in zip(pis_digits, WEIGHTS)) 127 | check_digit = 11 - (pis_sum % 11) 128 | 129 | return 0 if check_digit in [10, 11] else check_digit 130 | -------------------------------------------------------------------------------- /brutils/ibge/uf.py: -------------------------------------------------------------------------------- 1 | import unicodedata 2 | 3 | from brutils.data.enums.uf import UF, UF_CODE 4 | 5 | 6 | def convert_code_to_uf(code: str) -> str | None: 7 | """ 8 | Converts a given IBGE code (2-digit string) to its corresponding UF (state abbreviation). 9 | 10 | This function takes a 2-digit IBGE code and returns the corresponding UF code. 11 | It handles all Brazilian states and the Federal District. 12 | 13 | Args: 14 | code (str): The 2-digit IBGE code to be converted. 15 | 16 | Returns: 17 | str or None: The UF code corresponding to the IBGE code, 18 | or None if the IBGE code is invalid. 19 | 20 | Example: 21 | >>> convert_code_to_uf('12') 22 | 'AC' 23 | >>> convert_code_to_uf('33') 24 | 'RJ' 25 | >>> convert_code_to_uf('99') 26 | >>> 27 | """ 28 | if code not in UF_CODE.values: 29 | return None 30 | 31 | return UF_CODE(code).name 32 | 33 | 34 | def convert_uf_to_name(uf: str) -> str | None: 35 | """ 36 | Convert a Brazilian UF code (e.g., 'SP') to its full state name ('São Paulo'). 37 | 38 | - The lookup is case-insensitive and ignores surrounding whitespace. 39 | - Returns ``None`` if the input is invalid or the UF code is not recognized. 40 | 41 | Args: 42 | uf (str): A two-letter UF code (e.g., 'RJ', 'sp'). 43 | 44 | Returns: 45 | str | None: The full state name if found, or ``None`` if the code is invalid. 46 | 47 | Examples: 48 | >>> convert_uf_to_name('SP') 49 | 'São Paulo' 50 | >>> convert_uf_to_name('rj') 51 | 'Rio de Janeiro' 52 | """ 53 | if not uf or not isinstance(uf, str) or len(uf.strip()) != 2: 54 | return None 55 | 56 | federal_unit = uf.strip().upper() 57 | 58 | if federal_unit not in UF.names: 59 | return None 60 | 61 | result = UF[federal_unit].value 62 | 63 | return result 64 | 65 | 66 | def _normalize_text(text: str) -> str: 67 | """ 68 | Normalize text by removing accents and normalizing whitespace. 69 | 70 | Args: 71 | text (str): The text to normalize. 72 | 73 | Returns: 74 | str: The normalized text in uppercase. 75 | """ 76 | nfd = unicodedata.normalize("NFD", text) 77 | without_accents = "".join( 78 | char for char in nfd if unicodedata.category(char) != "Mn" 79 | ) 80 | 81 | normalized_spaces = " ".join(without_accents.split()) 82 | return normalized_spaces.upper() 83 | 84 | 85 | def convert_name_to_uf(state_name: str) -> str | None: 86 | """ 87 | Convert a Brazilian state name to its UF code. 88 | 89 | This function takes the full name of a Brazilian state and returns its 90 | corresponding two-letter UF code. The comparison is case-insensitive and 91 | ignores accents. 92 | 93 | Args: 94 | state_name (str): The full name of the state (e.g., 'São Paulo', 'sao paulo'). 95 | 96 | Returns: 97 | str | None: The UF code if found, or None if the state name is invalid. 98 | 99 | Examples: 100 | >>> convert_name_to_uf('São Paulo') 101 | 'SP' 102 | >>> convert_name_to_uf('sao paulo') 103 | 'SP' 104 | >>> convert_name_to_uf('Rio de Janeiro') 105 | 'RJ' 106 | >>> convert_name_to_uf('rio de janeiro') 107 | 'RJ' 108 | >>> convert_name_to_uf('Estado Inválido') 109 | >>> 110 | """ 111 | if not state_name or not isinstance(state_name, str): 112 | return None 113 | 114 | normalized_input = _normalize_text(state_name.strip()) 115 | 116 | for uf in UF: 117 | if _normalize_text(uf.value) == normalized_input: 118 | return uf.name 119 | 120 | return None 121 | -------------------------------------------------------------------------------- /brutils/currency.py: -------------------------------------------------------------------------------- 1 | from decimal import ROUND_DOWN, Decimal, InvalidOperation 2 | 3 | from num2words import num2words 4 | 5 | 6 | def format_currency(value: float | int | str | Decimal) -> str | None: 7 | """ 8 | Format a numeric value as Brazilian currency (R$). 9 | 10 | Args: 11 | value: The numeric value to format. Accepts float, int, str, or Decimal. 12 | 13 | Returns: 14 | Formatted currency string (e.g., "R$ 1.234,56") or None if invalid. 15 | 16 | Example: 17 | >>> format_currency(1234.56) 18 | 'R$ 1.234,56' 19 | >>> format_currency(0) 20 | 'R$ 0,00' 21 | >>> format_currency(-9876.54) 22 | 'R$ -9.876,54' 23 | >>> format_currency("invalid") 24 | None 25 | """ 26 | try: 27 | decimal_value = Decimal(value) 28 | formatted_value = ( 29 | f"R$ {decimal_value:,.2f}".replace(",", "_") 30 | .replace(".", ",") 31 | .replace("_", ".") 32 | ) 33 | return formatted_value 34 | except (InvalidOperation, TypeError, ValueError): 35 | return None 36 | 37 | 38 | def convert_real_to_text(amount: Decimal | float | int | str) -> str | None: 39 | """ 40 | Convert a monetary value in Brazilian Reais to textual representation. 41 | 42 | Args: 43 | amount: Monetary value to convert. Accepts Decimal, float, int, or str. 44 | 45 | Returns: 46 | Textual representation in Brazilian Portuguese, or None if invalid. 47 | 48 | Note: 49 | - Values are rounded down to 2 decimal places 50 | - Maximum supported value is 1 quadrillion reais 51 | - Negative values are prefixed with "Menos" 52 | 53 | Example: 54 | >>> convert_real_to_text(1523.45) 55 | 'Mil, quinhentos e vinte e três reais e quarenta e cinco centavos' 56 | >>> convert_real_to_text(1.00) 57 | 'Um real' 58 | >>> convert_real_to_text(0.50) 59 | 'Cinquenta centavos' 60 | >>> convert_real_to_text(0.00) 61 | 'Zero reais' 62 | """ 63 | try: 64 | amount = Decimal(str(amount)).quantize( 65 | Decimal("0.01"), rounding=ROUND_DOWN 66 | ) 67 | except (InvalidOperation, TypeError, ValueError): 68 | return None 69 | 70 | if amount.is_nan() or amount.is_infinite(): 71 | return None 72 | 73 | if abs(amount) > Decimal("1000000000000000.00"): # 1 quadrillion 74 | return None 75 | 76 | negative = amount < 0 77 | amount = abs(amount) 78 | 79 | reais = int(amount) 80 | centavos = int((amount - reais) * 100) 81 | 82 | parts = [] 83 | 84 | if reais > 0: 85 | """ 86 | Note: 87 | Although the `num2words` library provides a "to='currency'" feature, it has known 88 | issues with the representation of "zero reais" and "zero centavos". Therefore, this 89 | implementation uses only the traditional number-to-text conversion for better accuracy. 90 | """ 91 | reais_text = num2words(reais, lang="pt_BR") 92 | currency_text = "real" if reais == 1 else "reais" 93 | conector = "de " if reais_text.endswith(("lhão", "lhões")) else "" 94 | parts.append(f"{reais_text} {conector}{currency_text}") 95 | 96 | if centavos > 0: 97 | centavos_text = f"{num2words(centavos, lang='pt_BR')} {'centavo' if centavos == 1 else 'centavos'}" 98 | if reais > 0: 99 | parts.append(f"e {centavos_text}") 100 | else: 101 | parts.append(centavos_text) 102 | 103 | if reais == 0 and centavos == 0: 104 | parts.append("Zero reais") 105 | 106 | result = " ".join(parts) 107 | if negative: 108 | result = f"Menos {result}" 109 | 110 | return result.capitalize() 111 | -------------------------------------------------------------------------------- /tests/test_cpf.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from unittest.mock import patch 3 | 4 | from brutils.cpf import ( 5 | _checksum, 6 | _hashdigit, 7 | display, 8 | format_cpf, 9 | generate, 10 | is_valid, 11 | remove_symbols, 12 | sieve, 13 | validate, 14 | ) 15 | 16 | 17 | class TestCPF(TestCase): 18 | def test_sieve(self): 19 | self.assertEqual(sieve("00000000000"), "00000000000") 20 | self.assertEqual(sieve("123.456.789-10"), "12345678910") 21 | self.assertEqual(sieve("134..2435.-1892.-"), "13424351892") 22 | self.assertEqual(sieve("abc1230916*!*&#"), "abc1230916*!*&#") 23 | self.assertEqual( 24 | sieve("ab.c1.--.2-309.-1-.6-.*.-!*&#"), "abc1230916*!*&#" 25 | ) 26 | self.assertEqual(sieve("...---..."), "") 27 | 28 | def test_display(self): 29 | self.assertEqual(display("00000000011"), "000.000.000-11") 30 | self.assertIsNone(display("00000000000")) 31 | self.assertIsNone(display("0000000000a")) 32 | self.assertIsNone(display("000000000000")) 33 | 34 | def test_validate(self): 35 | self.assertIs(validate("52513127765"), True) 36 | self.assertIs(validate("52599927765"), True) 37 | self.assertIs(validate("00000000000"), False) 38 | 39 | def test_is_valid(self): 40 | # When cpf is not string, returns False 41 | self.assertIs(is_valid(1), False) 42 | 43 | # When cpf's len is different of 11, returns False 44 | self.assertIs(is_valid("1"), False) 45 | 46 | # When cpf does not contain only digits, returns False 47 | self.assertIs(is_valid("1112223334-"), False) 48 | 49 | # When CPF has only the same digit, returns false 50 | self.assertIs(is_valid("11111111111"), False) 51 | 52 | # When rest_1 is lt 2 and the 10th digit is not 0, returns False 53 | self.assertIs(is_valid("11111111215"), False) 54 | 55 | # When rest_1 is gte 2 and the 10th digit is not (11 - rest), returns 56 | # False 57 | self.assertIs(is_valid("11144477705"), False) 58 | 59 | # When rest_2 is lt 2 and the 11th digit is not 0, returns False 60 | self.assertIs(is_valid("11111111204"), False) 61 | 62 | # When rest_2 is gte 2 and the 11th digit is not (11 - rest), returns 63 | # False 64 | self.assertIs(is_valid("11144477732"), False) 65 | 66 | # When cpf is valid 67 | self.assertIs(is_valid("11144477735"), True) 68 | self.assertIs(is_valid("11111111200"), True) 69 | 70 | def test_generate(self): 71 | for _ in range(10_000): 72 | self.assertIs(validate(generate()), True) 73 | self.assertIsNotNone(display(generate())) 74 | 75 | def test__hashdigit(self): 76 | self.assertEqual(_hashdigit("000000000", 10), 0) 77 | self.assertEqual(_hashdigit("0000000000", 11), 0) 78 | self.assertEqual(_hashdigit("52513127765", 10), 6) 79 | self.assertEqual(_hashdigit("52513127765", 11), 5) 80 | 81 | def test_checksum(self): 82 | self.assertEqual(_checksum("000000000"), "00") 83 | self.assertEqual(_checksum("525131277"), "65") 84 | 85 | 86 | @patch("brutils.cpf.sieve") 87 | class TestRemoveSymbols(TestCase): 88 | def test_remove_symbols(self, mock_sieve): 89 | # When call remove_symbols, it calls sieve 90 | remove_symbols("123.456.789-10") 91 | mock_sieve.assert_called() 92 | 93 | 94 | @patch("brutils.cpf.is_valid") 95 | class TestIsValidToFormat(TestCase): 96 | def test_when_cpf_is_valid_returns_true_to_format(self, mock_is_valid): 97 | mock_is_valid.return_value = True 98 | 99 | # When cpf is_valid, returns formatted cpf 100 | self.assertEqual(format_cpf("11144477735"), "111.444.777-35") 101 | 102 | # Checks if function is_valid_cpf is called 103 | mock_is_valid.assert_called_once_with("11144477735") 104 | 105 | def test_when_cpf_is_not_valid_returns_none(self, mock_is_valid): 106 | mock_is_valid.return_value = False 107 | 108 | # When cpf isn't valid, returns None 109 | self.assertIsNone(format_cpf("11144477735")) 110 | 111 | 112 | if __name__ == "__main__": 113 | main() 114 | -------------------------------------------------------------------------------- /tests/test_cnpj.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from unittest.mock import patch 3 | 4 | from brutils.cnpj import ( 5 | _checksum, 6 | _hashdigit, 7 | display, 8 | format_cnpj, 9 | generate, 10 | is_valid, 11 | remove_symbols, 12 | sieve, 13 | validate, 14 | ) 15 | 16 | 17 | class TestCNPJ(TestCase): 18 | def test_sieve(self): 19 | self.assertEqual(sieve("00000000000"), "00000000000") 20 | self.assertEqual(sieve("12.345.678/0001-90"), "12345678000190") 21 | self.assertEqual(sieve("134..2435/.-1892.-"), "13424351892") 22 | self.assertEqual(sieve("abc1230916*!*&#"), "abc1230916*!*&#") 23 | self.assertEqual( 24 | sieve("ab.c1.--.2-3/09.-1-./6/-.*.-!*&#"), "abc1230916*!*&#" 25 | ) 26 | self.assertEqual(sieve("/...---.../"), "") 27 | 28 | def test_display(self): 29 | self.assertEqual(display("00000000000109"), "00.000.000/0001-09") 30 | self.assertIsNone(display("00000000000000")) 31 | self.assertIsNone(display("0000000000000")) 32 | self.assertIsNone(display("0000000000000a")) 33 | 34 | def test_validate(self): 35 | self.assertIs(validate("34665388000161"), True) 36 | self.assertIs(validate("52599927000100"), False) 37 | self.assertIs(validate("00000000000"), False) 38 | 39 | def test_is_valid(self): 40 | # When CNPJ is not string, returns False 41 | self.assertIs(is_valid(1), False) 42 | 43 | # When CNPJ's len is different of 14, returns False 44 | self.assertIs(is_valid("1"), False) 45 | 46 | # When CNPJ does not contain only digits, returns False 47 | self.assertIs(is_valid("1112223334445-"), False) 48 | 49 | # When CNPJ has only the same digit, returns false 50 | self.assertIs(is_valid("11111111111111"), False) 51 | 52 | # When rest_1 is lt 2 and the 13th digit is not 0, returns False 53 | self.assertIs(is_valid("1111111111315"), False) 54 | 55 | # When rest_1 is gte 2 and the 13th digit is not (11 - rest), returns 56 | # False 57 | self.assertIs(is_valid("1111111111115"), False) 58 | 59 | # When rest_2 is lt 2 and the 14th digit is not 0, returns False 60 | self.assertIs(is_valid("11111111121205"), False) 61 | 62 | # When rest_2 is gte 2 and the 14th digit is not (11 - rest), returns 63 | # False 64 | self.assertIs(is_valid("11111111113105"), False) 65 | 66 | # When CNPJ is valid 67 | self.assertIs(is_valid("34665388000161"), True) 68 | self.assertIs(is_valid("01838723000127"), True) 69 | 70 | def test_generate(self): 71 | for _ in range(10_000): 72 | self.assertIs(validate(generate()), True) 73 | self.assertIsNotNone(display(generate())) 74 | 75 | def test__hashdigit(self): 76 | self.assertEqual(_hashdigit("00000000000000", 13), 0) 77 | self.assertEqual(_hashdigit("00000000000000", 14), 0) 78 | self.assertEqual(_hashdigit("52513127000292", 13), 9) 79 | self.assertEqual(_hashdigit("52513127000292", 14), 9) 80 | 81 | def test__checksum(self): 82 | self.assertEqual(_checksum("00000000000000"), "00") 83 | self.assertEqual(_checksum("52513127000299"), "99") 84 | 85 | 86 | @patch("brutils.cnpj.sieve") 87 | class TestRemoveSymbols(TestCase): 88 | def test_remove_symbols(self, mock_sieve): 89 | # When call remove_symbols, it calls sieve 90 | remove_symbols("12.345.678/0001-90") 91 | mock_sieve.assert_called() 92 | 93 | 94 | @patch("brutils.cnpj.is_valid") 95 | class TestIsValidToFormat(TestCase): 96 | def test_when_cnpj_is_valid_returns_true_to_format(self, mock_is_valid): 97 | mock_is_valid.return_value = True 98 | 99 | # When cnpj is_valid, returns formatted cnpj 100 | self.assertEqual(format_cnpj("01838723000127"), "01.838.723/0001-27") 101 | 102 | # Checks if function is_valid_cnpj is called 103 | mock_is_valid.assert_called_once_with("01838723000127") 104 | 105 | def test_when_cnpj_is_not_valid_returns_none(self, mock_is_valid): 106 | mock_is_valid.return_value = False 107 | 108 | # When cnpj isn't valid, returns None 109 | self.assertIsNone(format_cnpj("01838723000127")) 110 | 111 | 112 | if __name__ == "__main__": 113 | main() 114 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import pkgutil 4 | import unittest 5 | 6 | 7 | def get_public_functions(module): 8 | """Get all public functions and methods in the module.""" 9 | return [ 10 | name 11 | for name, obj in inspect.getmembers(module, inspect.isfunction) 12 | if not is_private_function(name) and inspect.getmodule(obj) == module 13 | ] + [ 14 | name 15 | for name, obj in inspect.getmembers(module, inspect.ismethod) 16 | if obj.__module__ == module.__name__ and not name.startswith("_") 17 | ] 18 | 19 | 20 | def get_imported_methods(module): 21 | """Get all names in the module's namespace.""" 22 | return [ 23 | name 24 | for name in dir(module) 25 | if not is_private_function(name) and not is_standard_function(name) 26 | ] 27 | 28 | 29 | def is_private_function(name): 30 | """Check if a function is private.""" 31 | return name.startswith("_") 32 | 33 | 34 | def is_standard_function(name): 35 | """Check if a function name is a standard or built-in function.""" 36 | return name in dir(__builtins__) or ( 37 | name.startswith("__") and name.endswith("__") 38 | ) 39 | 40 | 41 | class TestImports(unittest.TestCase): 42 | @classmethod 43 | def setUpClass(cls): 44 | """Set up the package and its __init__.py module.""" 45 | cls.package_name = "brutils" 46 | cls.package = importlib.import_module(cls.package_name) 47 | cls.init_module = importlib.import_module( 48 | f"{cls.package_name}.__init__" 49 | ) 50 | 51 | cls.all_public_methods = [] 52 | cls.imported_methods = [] 53 | 54 | # Iterate over all submodules and collect their public methods 55 | for _, module_name, _ in pkgutil.walk_packages( 56 | cls.package.__path__, cls.package.__name__ + "." 57 | ): 58 | module = importlib.import_module(module_name) 59 | cls.all_public_methods.extend(get_public_functions(module)) 60 | 61 | # Collect imported methods from __init__.py 62 | cls.imported_methods = get_imported_methods(cls.init_module) 63 | 64 | # Filter out standard or built-in functions 65 | cls.filtered_public_methods = [ 66 | method 67 | for method in cls.all_public_methods 68 | if not is_standard_function(method) 69 | ] 70 | cls.filtered_imported_methods = [ 71 | method 72 | for method in cls.imported_methods 73 | if not is_standard_function(method) 74 | ] 75 | 76 | # Remove specific old methods 77 | cls.filtered_public_methods = [ 78 | method 79 | for method in cls.filtered_public_methods 80 | if method not in {"sieve", "display", "validate"} 81 | ] 82 | 83 | # Ensure all public methods are included in __all__ 84 | cls.all_defined_names = dir(cls.init_module) 85 | cls.public_methods_in_all = getattr(cls.init_module, "__all__", []) 86 | 87 | cls.missing_imports = [ 88 | method 89 | for method in cls.filtered_public_methods 90 | if method not in cls.filtered_imported_methods 91 | ] 92 | 93 | def test_public_methods_in_imports(self): 94 | """Test that all public methods are imported or aliased.""" 95 | aliases_imports = [ 96 | method 97 | for method in self.filtered_imported_methods 98 | if method not in self.filtered_public_methods 99 | ] 100 | 101 | diff = len(self.missing_imports) - len(aliases_imports) 102 | 103 | if diff != 0: 104 | self.fail( 105 | f"{diff} public method(s) missing from imports at __init__.py. You need to import the new brutils features methods inside the brutils/__init__.py file" 106 | ) 107 | 108 | def test_public_methods_in_all(self): 109 | """Test that all public methods are included in __all__.""" 110 | missing_in_all = set(self.filtered_imported_methods) - set( 111 | self.public_methods_in_all 112 | ) 113 | 114 | if missing_in_all: 115 | self.fail( 116 | f"Public method(s) missing from __all__: {missing_in_all}. You need to add the new brutils features methods names to the list __all__ in the brutils/__init__.py file" 117 | ) 118 | 119 | 120 | if __name__ == "__main__": 121 | unittest.main() 122 | -------------------------------------------------------------------------------- /tests/phone/test_phone.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | 3 | from brutils.phone import ( 4 | _is_valid_landline, 5 | _is_valid_mobile, 6 | format_phone, 7 | generate, 8 | is_valid, 9 | remove_international_dialing_code, 10 | remove_symbols_phone, 11 | ) 12 | 13 | 14 | class TestPhone(TestCase): 15 | def test_remove_symbols_phone(self): 16 | # When the string empty, it returns an empty string 17 | self.assertEqual(remove_symbols_phone(""), "") 18 | 19 | # When there are no symbols to remove, it returns the same string 20 | self.assertEqual(remove_symbols_phone("21994029275"), "21994029275") 21 | 22 | # When there are symbols to remove, it returns the string without 23 | # symbols 24 | self.assertEqual(remove_symbols_phone("(21)99402-9275"), "21994029275") 25 | self.assertEqual(remove_symbols_phone("(21)2569-6969"), "2125696969") 26 | 27 | # When there are extra symbols, it only removes the specified symbols 28 | self.assertEqual( 29 | remove_symbols_phone("(21) 99402-9275!"), "21994029275!" 30 | ) 31 | 32 | # When the string contains non-numeric characters, it returns the 33 | # string without the specified symbols 34 | self.assertEqual(remove_symbols_phone("(21)ABC-DEF"), "21ABCDEF") 35 | 36 | # When the phone number contains a plus symbol and spaces, they are 37 | # removed 38 | self.assertEqual( 39 | remove_symbols_phone("+55 21 99402-9275"), "5521994029275" 40 | ) 41 | 42 | # When the phone number contains multiple spaces, all are removed 43 | self.assertEqual( 44 | remove_symbols_phone("55 21 99402 9275"), "5521994029275" 45 | ) 46 | 47 | # When the phone number contains a mixture of all specified symbols, 48 | # all are removed 49 | self.assertEqual( 50 | remove_symbols_phone("+55 (21) 99402-9275"), "5521994029275" 51 | ) 52 | 53 | def test_format_phone_number(self): 54 | # When is a invalid number 55 | self.assertEqual(format_phone("333333"), None) 56 | 57 | # When is a mobile number 58 | self.assertEqual(format_phone("21994029275"), "(21)99402-9275") 59 | self.assertEqual(format_phone("21994029275"), "(21)99402-9275") 60 | self.assertEqual(format_phone("21994029275"), "(21)99402-9275") 61 | self.assertEqual(format_phone("11994029275"), "(11)99402-9275") 62 | 63 | # When is a landline number 64 | self.assertEqual(format_phone("1928814933"), "(19)2881-4933") 65 | self.assertEqual(format_phone("1938814933"), "(19)3881-4933") 66 | self.assertEqual(format_phone("1948814933"), "(19)4881-4933") 67 | self.assertEqual(format_phone("1958814933"), "(19)5881-4933") 68 | self.assertEqual(format_phone("3333333333"), "(33)3333-3333") 69 | 70 | def test_generate(self): 71 | for _ in range(25): 72 | with self.subTest(): 73 | no_type_phone_generated = generate() 74 | self.assertIs(is_valid(no_type_phone_generated), True) 75 | mobile_phone_generated = generate("mobile") 76 | self.assertIs(_is_valid_mobile(mobile_phone_generated), True) 77 | landline_phone_generated = generate("landline") 78 | self.assertIs( 79 | _is_valid_landline(landline_phone_generated), True 80 | ) 81 | 82 | def test_remove_international_dialing_code(self): 83 | # When the phone number does not have the international code, 84 | # return the same phone number 85 | self.assertEqual( 86 | remove_international_dialing_code("21994029275"), "21994029275" 87 | ) 88 | self.assertEqual( 89 | remove_international_dialing_code("55994024478"), "55994024478" 90 | ) 91 | self.assertEqual( 92 | remove_international_dialing_code("994024478"), "994024478" 93 | ) 94 | 95 | # When the phone number has the international code, 96 | # return phone number without international code 97 | self.assertEqual( 98 | remove_international_dialing_code("5521994029275"), "21994029275" 99 | ) 100 | self.assertEqual( 101 | remove_international_dialing_code("+5521994029275"), "+21994029275" 102 | ) 103 | self.assertEqual( 104 | remove_international_dialing_code("+5555994029275"), "+55994029275" 105 | ) 106 | self.assertEqual( 107 | remove_international_dialing_code("5511994029275"), "11994029275" 108 | ) 109 | 110 | 111 | if __name__ == "__main__": 112 | main() 113 | -------------------------------------------------------------------------------- /brutils/__init__.py: -------------------------------------------------------------------------------- 1 | # CEP Imports 2 | from brutils.cep import ( 3 | format_cep, 4 | get_address_from_cep, 5 | get_cep_information_from_address, 6 | ) 7 | from brutils.cep import generate as generate_cep 8 | from brutils.cep import is_valid as is_valid_cep 9 | 10 | # CNH Imports 11 | from brutils.cnh import is_valid_cnh as is_valid_cnh 12 | 13 | # CNPJ Imports 14 | from brutils.cnpj import format_cnpj 15 | from brutils.cnpj import generate as generate_cnpj 16 | from brutils.cnpj import is_valid as is_valid_cnpj 17 | from brutils.cnpj import remove_symbols as remove_symbols_cnpj 18 | 19 | # CPF Imports 20 | from brutils.cpf import format_cpf 21 | from brutils.cpf import generate as generate_cpf 22 | from brutils.cpf import is_valid as is_valid_cpf 23 | from brutils.cpf import remove_symbols as remove_symbols_cpf 24 | 25 | # Currency 26 | from brutils.currency import convert_real_to_text, format_currency 27 | 28 | # Date Utils Import 29 | from brutils.date_utils import convert_date_to_text, is_holiday 30 | 31 | # Email Import 32 | from brutils.email import is_valid as is_valid_email 33 | 34 | # IBGE Imports 35 | from brutils.ibge.municipality import ( 36 | get_code_by_municipality_name, 37 | get_municipality_by_code, 38 | ) 39 | from brutils.ibge.uf import ( 40 | convert_code_to_uf, 41 | convert_name_to_uf, 42 | convert_uf_to_name, 43 | ) 44 | 45 | # Legal Nature imports 46 | from brutils.legal_nature import get_description as get_natureza_legal_nature 47 | from brutils.legal_nature import is_valid as is_valid_legal_nature 48 | from brutils.legal_nature import list_all as list_all_legal_nature 49 | 50 | # Legal Process Imports 51 | from brutils.legal_process import format_legal_process 52 | from brutils.legal_process import generate as generate_legal_process 53 | from brutils.legal_process import is_valid as is_valid_legal_process 54 | from brutils.legal_process import remove_symbols as remove_symbols_legal_process 55 | 56 | # License Plate Imports 57 | from brutils.license_plate import ( 58 | convert_to_mercosul as convert_license_plate_to_mercosul, 59 | ) 60 | from brutils.license_plate import format_license_plate 61 | from brutils.license_plate import generate as generate_license_plate 62 | from brutils.license_plate import get_format as get_format_license_plate 63 | from brutils.license_plate import is_valid as is_valid_license_plate 64 | from brutils.license_plate import remove_symbols as remove_symbols_license_plate 65 | 66 | # Phone Imports 67 | from brutils.phone import ( 68 | format_phone, 69 | remove_international_dialing_code, 70 | remove_symbols_phone, 71 | ) 72 | from brutils.phone import generate as generate_phone 73 | from brutils.phone import is_valid as is_valid_phone 74 | 75 | # PIS Imports 76 | from brutils.pis import format_pis 77 | from brutils.pis import generate as generate_pis 78 | from brutils.pis import is_valid as is_valid_pis 79 | from brutils.pis import remove_symbols as remove_symbols_pis 80 | 81 | # RENAVAM Imports 82 | from brutils.renavam import is_valid_renavam 83 | 84 | # Voter ID Imports 85 | from brutils.voter_id import format_voter_id 86 | from brutils.voter_id import generate as generate_voter_id 87 | from brutils.voter_id import is_valid as is_valid_voter_id 88 | 89 | # Defining __all__ to expose the public methods 90 | __all__ = [ 91 | # CEP 92 | "format_cep", 93 | "get_address_from_cep", 94 | "get_cep_information_from_address", 95 | "generate_cep", 96 | "is_valid_cep", 97 | # CNPJ 98 | "format_cnpj", 99 | "generate_cnpj", 100 | "is_valid_cnpj", 101 | "remove_symbols_cnpj", 102 | # CPF 103 | "format_cpf", 104 | "generate_cpf", 105 | "is_valid_cpf", 106 | "remove_symbols_cpf", 107 | # CNH 108 | "is_valid_cnh", 109 | # Email 110 | "is_valid_email", 111 | # Legal Process 112 | "format_legal_process", 113 | "generate_legal_process", 114 | "is_valid_legal_process", 115 | "remove_symbols_legal_process", 116 | # License Plate 117 | "convert_license_plate_to_mercosul", 118 | "format_license_plate", 119 | "generate_license_plate", 120 | "get_format_license_plate", 121 | "is_valid_license_plate", 122 | "remove_symbols_license_plate", 123 | # Phone 124 | "format_phone", 125 | "remove_international_dialing_code", 126 | "remove_symbols_phone", 127 | "generate_phone", 128 | "is_valid_phone", 129 | # PIS 130 | "format_pis", 131 | "generate_pis", 132 | "is_valid_pis", 133 | "remove_symbols_pis", 134 | # RENAVAM 135 | "is_valid_renavam", 136 | # Voter ID 137 | "format_voter_id", 138 | "generate_voter_id", 139 | "is_valid_voter_id", 140 | # IBGE 141 | "convert_code_to_uf", 142 | "convert_name_to_uf", 143 | "convert_uf_to_name", 144 | "get_code_by_municipality_name", 145 | "get_municipality_by_code", 146 | # Date Utils 147 | "is_holiday", 148 | "convert_date_to_text", 149 | # Currency 150 | "format_currency", 151 | "convert_real_to_text", 152 | # Legal Nature 153 | "is_valid_legal_nature", 154 | "get_natureza_legal_nature", 155 | "list_all_legal_nature", 156 | ] 157 | -------------------------------------------------------------------------------- /tests/license_plate/test_license_plate.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from unittest.mock import patch 3 | 4 | from brutils.license_plate import ( 5 | _is_valid_mercosul, 6 | _is_valid_old_format, 7 | convert_to_mercosul, 8 | format_license_plate, 9 | generate, 10 | get_format, 11 | remove_symbols, 12 | ) 13 | 14 | 15 | class TestLicensePlate(TestCase): 16 | def test_remove_symbols(self): 17 | self.assertEqual(remove_symbols("ABC-123"), "ABC123") 18 | self.assertEqual(remove_symbols("abc123"), "abc123") 19 | self.assertEqual(remove_symbols("ABCD123"), "ABCD123") 20 | self.assertEqual(remove_symbols("A-B-C-1-2-3"), "ABC123") 21 | self.assertEqual(remove_symbols("@abc#-#123@"), "@abc##123@") 22 | self.assertEqual(remove_symbols("@---#"), "@#") 23 | self.assertEqual(remove_symbols("---"), "") 24 | 25 | def test_convert_license_plate_to_mercosul(self): 26 | # when license plate is not an instance of string, returns None 27 | self.assertIsNone(convert_to_mercosul(1234567)) 28 | 29 | # when license plate has a length different from 7, returns None 30 | self.assertIsNone(convert_to_mercosul("ABC123")) 31 | self.assertIsNone(convert_to_mercosul("ABC12356")) 32 | 33 | # when then license plate's 5th character is not a number, return None 34 | self.assertIsNone(convert_to_mercosul("ABC1A34")) 35 | self.assertIsNone(convert_to_mercosul("ABC1-34")) 36 | self.assertIsNone(convert_to_mercosul("ABC1*34")) 37 | self.assertIsNone(convert_to_mercosul("ABC1_34")) 38 | self.assertIsNone(convert_to_mercosul("ABC1%34")) 39 | self.assertIsNone(convert_to_mercosul("ABC1 34")) 40 | 41 | # when then license plate's 5th character is 0, return with a letter A 42 | self.assertEqual(convert_to_mercosul("AAA1011"), "AAA1A11") 43 | # when then license plate's 5th character is 1, return with a letter B 44 | self.assertEqual(convert_to_mercosul("AAA1111"), "AAA1B11") 45 | # when then license plate's 5th character is 2, return with a letter C 46 | self.assertEqual(convert_to_mercosul("AAA1211"), "AAA1C11") 47 | # when then license plate's 5th character is 3, return with a letter D 48 | self.assertEqual(convert_to_mercosul("AAA1311"), "AAA1D11") 49 | # when then license plate's 5th character is 4, return with a letter E 50 | self.assertEqual(convert_to_mercosul("AAA1411"), "AAA1E11") 51 | # when then license plate's 5th character is 5, return with a letter F 52 | self.assertEqual(convert_to_mercosul("AAA1511"), "AAA1F11") 53 | # when then license plate's 5th character is 6, return with a letter G 54 | self.assertEqual(convert_to_mercosul("AAA1611"), "AAA1G11") 55 | # when then license plate's 5th character is 7, return with a letter H 56 | self.assertEqual(convert_to_mercosul("AAA1711"), "AAA1H11") 57 | # when then license plate's 5th character is 8, return with a letter I 58 | self.assertEqual(convert_to_mercosul("AAA1811"), "AAA1I11") 59 | # when then license plate's 5th character is 9, return with a letter J 60 | self.assertEqual(convert_to_mercosul("AAA1911"), "AAA1J11") 61 | 62 | # when then license is provided in lowercase, it's correctly converted 63 | # and then returned value is in uppercase 64 | self.assertEqual(convert_to_mercosul("abc1234"), "ABC1C34") 65 | 66 | def test_format_license_plate(self): 67 | self.assertEqual(format_license_plate("ABC1234"), "ABC-1234") 68 | self.assertEqual(format_license_plate("abc1234"), "ABC-1234") 69 | self.assertEqual(format_license_plate("ABC1D23"), "ABC1D23") 70 | self.assertEqual(format_license_plate("abc1d23"), "ABC1D23") 71 | self.assertIsNone(format_license_plate("ABCD123")) 72 | 73 | def test_get_format(self): 74 | # Old format 75 | self.assertEqual(get_format("ABC1234"), "LLLNNNN") 76 | self.assertEqual(get_format("abc1234"), "LLLNNNN") 77 | 78 | # Mercosul 79 | self.assertEqual(get_format("ABC4E67"), "LLLNLNN") 80 | self.assertEqual(get_format("XXX9X99"), "LLLNLNN") 81 | 82 | # Invalid 83 | self.assertIsNone(get_format(None)) 84 | self.assertIsNone(get_format("")) 85 | self.assertIsNone(get_format("ABC-1D23")) 86 | self.assertIsNone(get_format("invalid plate")) 87 | 88 | def test_generate_license_plate(self): 89 | with patch("brutils.license_plate.choice", return_value="X"): 90 | with patch("brutils.license_plate.randint", return_value=9): 91 | self.assertEqual(generate(format="LLLNNNN"), "XXX9999") 92 | self.assertEqual(generate(format="LLLNLNN"), "XXX9X99") 93 | 94 | for _ in range(10_000): 95 | self.assertTrue(_is_valid_mercosul(generate(format="LLLNLNN"))) 96 | 97 | for _ in range(10_000): 98 | self.assertTrue(_is_valid_old_format(generate(format="LLLNNNN"))) 99 | 100 | # When no format is provided, returns a valid Mercosul license plate 101 | self.assertTrue(_is_valid_mercosul(generate())) 102 | 103 | # When invalid format is provided, returns None 104 | self.assertIsNone(generate("LNLNLNL")) 105 | 106 | 107 | if __name__ == "__main__": 108 | main() 109 | -------------------------------------------------------------------------------- /tests/test_voter_id.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | 3 | from brutils.voter_id import ( 4 | _calculate_vd1, 5 | _calculate_vd2, 6 | _get_federative_union, 7 | _get_sequential_number, 8 | _get_verifying_digits, 9 | _is_length_valid, 10 | format_voter_id, 11 | generate, 12 | is_valid, 13 | ) 14 | 15 | 16 | class TestIsValid(TestCase): 17 | def test_valid_voter_id(self): 18 | # test a valid voter id number 19 | voter_id = "217633460930" 20 | self.assertIs(is_valid(voter_id), True) 21 | self.assertIsInstance(is_valid(voter_id), bool) 22 | 23 | def test_invalid_voter_id(self): 24 | # test an invalid voter id number (dv1 & UF fail) 25 | voter_id = "123456789011" 26 | self.assertIs(is_valid(voter_id), False) 27 | 28 | def test_invalid_length(self): 29 | # Test an invalid length for voter id 30 | invalid_length_short = "12345678901" 31 | invalid_length_long = "1234567890123" 32 | self.assertIs(is_valid(invalid_length_short), False) 33 | self.assertIs(is_valid(invalid_length_long), False) 34 | 35 | def test_invalid_characters(self): 36 | # Test voter id with non-numeric characters 37 | invalid_characters = "ABCD56789012" 38 | invalid_characters_space = "217633 460 930" 39 | self.assertIs(is_valid(invalid_characters), False) 40 | self.assertIs(is_valid(invalid_characters_space), False) 41 | 42 | def test_valid_special_case(self): 43 | # Test a valid edge case (SP & MG with 13 digits) 44 | valid_special = "3244567800167" 45 | self.assertIs(is_valid(valid_special), True) 46 | 47 | def test_invalid_vd1(self): 48 | voter_id = "427503840223" 49 | self.assertIs(is_valid(voter_id), False) 50 | 51 | def test_invalid_vd2(self): 52 | voter_id = "427503840214" 53 | self.assertIs(is_valid(voter_id), False) 54 | 55 | def test_get_voter_id_parts(self): 56 | voter_id = "12345678AB12" 57 | 58 | sequential_number = _get_sequential_number(voter_id) 59 | federative_union = _get_federative_union(voter_id) 60 | verifying_digits = _get_verifying_digits(voter_id) 61 | 62 | self.assertEqual(sequential_number, "12345678") 63 | self.assertIsInstance(sequential_number, str) 64 | 65 | self.assertEqual(federative_union, "AB") 66 | self.assertIsInstance(federative_union, str) 67 | 68 | self.assertEqual(verifying_digits, "12") 69 | self.assertIsInstance(verifying_digits, str) 70 | 71 | def test_valid_length_verify(self): 72 | voter_id = "123456789012" 73 | self.assertIs(_is_length_valid(voter_id), True) 74 | self.assertIsInstance(_is_length_valid(voter_id), bool) 75 | 76 | def test_invalid_length_verify(self): 77 | voter_id = "12345678AB123" # Invalid length 78 | self.assertIs(_is_length_valid(voter_id), False) 79 | 80 | def test_calculate_vd1(self): 81 | self.assertIs(_calculate_vd1("07881476", "03"), 6) 82 | 83 | # test edge case: when federative union is SP and rest is 0, declare vd1 as 1 84 | self.assertIs(_calculate_vd1("73146499", "01"), 1) 85 | # test edge case: when federative union is MG and rest is 0, declare vd1 as 1 86 | self.assertIs(_calculate_vd1("42750359", "02"), 1) 87 | # test edge case: rest is 10, declare vd1 as 0 88 | self.assertIs(_calculate_vd1("73146415", "03"), 0) 89 | self.assertIsInstance(_calculate_vd1("73146415", "03"), int) 90 | 91 | def test_calculate_vd2(self): 92 | self.assertIs(_calculate_vd2("02", 7), 2) 93 | # edge case: if rest == 10, declare vd2 as zero 94 | self.assertIs(_calculate_vd2("03", 7), 0) 95 | # edge case: if UF is "01" (for SP) and rest == 0 96 | # declare dv2 as 1 instead 97 | self.assertIs(_calculate_vd2("01", 4), 1) 98 | # edge case: if UF is "02" (for MG) and rest == 0 99 | # declare dv2 as 1 instead 100 | self.assertIs(_calculate_vd2("02", 8), 1) 101 | self.assertIsInstance(_calculate_vd2("02", 8), int) 102 | 103 | def test_generate_voter_id(self): 104 | # test if is_valid a voter id from MG 105 | voter_id = generate(federative_union="MG") 106 | self.assertIs(is_valid(voter_id), True) 107 | 108 | # test if is_valid a voter id from AC 109 | voter_id = generate(federative_union="AC") 110 | self.assertIs(is_valid(voter_id), True) 111 | 112 | # test if is_valid a voter id from foreigner 113 | voter_id = generate() 114 | self.assertIs(is_valid(voter_id), True) 115 | self.assertIsInstance(voter_id, str) 116 | 117 | # test if UF is not valid 118 | voter_id = generate(federative_union="XX") 119 | self.assertIs(is_valid(voter_id), False) 120 | self.assertIsNone(voter_id) 121 | 122 | def test_format_voter_id(self): 123 | self.assertEqual(format_voter_id("277627122852"), "2776 2712 28 52") 124 | self.assertIsInstance(format_voter_id("277627122852"), str) 125 | self.assertIsNone(format_voter_id("00000000000")) 126 | self.assertIsNone(format_voter_id("0000000000a")) 127 | self.assertIsNone(format_voter_id("000000000000")) 128 | self.assertIsNone(format_voter_id("800911840197")) 129 | 130 | 131 | if __name__ == "__main__": 132 | main() 133 | -------------------------------------------------------------------------------- /tests/test_currency.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from unittest import TestCase 3 | 4 | from brutils.currency import convert_real_to_text, format_currency 5 | 6 | 7 | class TestFormatCurrency(TestCase): 8 | def test_when_value_is_a_decimal_value(self): 9 | assert format_currency(Decimal("123236.70")) == "R$ 123.236,70" 10 | 11 | def test_when_value_is_a_float_value(self): 12 | assert format_currency(123236.70) == "R$ 123.236,70" 13 | 14 | def test_when_value_is_negative(self): 15 | assert format_currency(-123236.70) == "R$ -123.236,70" 16 | 17 | def test_when_value_is_zero(self): 18 | assert format_currency(0) == "R$ 0,00" 19 | 20 | def test_value_decimal_replace_rounding(self): 21 | assert format_currency(-123236.7676) == "R$ -123.236,77" 22 | 23 | def test_when_value_is_not_a_valid_currency(self): 24 | assert format_currency("not a number") is None 25 | assert format_currency("09809,87") is None 26 | assert format_currency("897L") is None 27 | 28 | 29 | class TestConvertRealToText(TestCase): 30 | def test_convert_real_to_text(self): 31 | self.assertEqual(convert_real_to_text(0.00), "Zero reais") 32 | self.assertEqual(convert_real_to_text(0.01), "Um centavo") 33 | self.assertEqual(convert_real_to_text(0.50), "Cinquenta centavos") 34 | self.assertEqual(convert_real_to_text(1.00), "Um real") 35 | self.assertEqual( 36 | convert_real_to_text(-50.25), 37 | "Menos cinquenta reais e vinte e cinco centavos", 38 | ) 39 | self.assertEqual( 40 | convert_real_to_text(1523.45), 41 | "Mil, quinhentos e vinte e três reais e quarenta e cinco centavos", 42 | ) 43 | self.assertEqual(convert_real_to_text(1000000.00), "Um milhão de reais") 44 | self.assertEqual( 45 | convert_real_to_text(2000000.00), "Dois milhões de reais" 46 | ) 47 | self.assertEqual( 48 | convert_real_to_text(1000000000.00), "Um bilhão de reais" 49 | ) 50 | self.assertEqual( 51 | convert_real_to_text(2000000000.00), "Dois bilhões de reais" 52 | ) 53 | self.assertEqual( 54 | convert_real_to_text(1000000000000.00), "Um trilhão de reais" 55 | ) 56 | self.assertEqual( 57 | convert_real_to_text(2000000000000.00), "Dois trilhões de reais" 58 | ) 59 | self.assertEqual( 60 | convert_real_to_text(1000000.45), 61 | "Um milhão de reais e quarenta e cinco centavos", 62 | ) 63 | self.assertEqual( 64 | convert_real_to_text(2000000000.99), 65 | "Dois bilhões de reais e noventa e nove centavos", 66 | ) 67 | self.assertEqual( 68 | convert_real_to_text(1234567890.50), 69 | "Um bilhão, duzentos e trinta e quatro milhões, quinhentos e sessenta e sete mil, oitocentos e noventa reais e cinquenta centavos", 70 | ) 71 | 72 | # Almost zero values 73 | self.assertEqual(convert_real_to_text(0.001), "Zero reais") 74 | self.assertEqual(convert_real_to_text(0.009), "Zero reais") 75 | 76 | # Negative milions 77 | self.assertEqual( 78 | convert_real_to_text(-1000000.00), "Menos um milhão de reais" 79 | ) 80 | self.assertEqual( 81 | convert_real_to_text(-2000000.50), 82 | "Menos dois milhões de reais e cinquenta centavos", 83 | ) 84 | 85 | # billions with cents 86 | self.assertEqual( 87 | convert_real_to_text(1000000000.01), 88 | "Um bilhão de reais e um centavo", 89 | ) 90 | self.assertEqual( 91 | convert_real_to_text(1000000000.99), 92 | "Um bilhão de reais e noventa e nove centavos", 93 | ) 94 | 95 | self.assertEqual( 96 | convert_real_to_text(999999999999.99), 97 | "Novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais e noventa e nove centavos", 98 | ) 99 | 100 | # trillions with cents 101 | self.assertEqual( 102 | convert_real_to_text(1000000000000.01), 103 | "Um trilhão de reais e um centavo", 104 | ) 105 | self.assertEqual( 106 | convert_real_to_text(1000000000000.99), 107 | "Um trilhão de reais e noventa e nove centavos", 108 | ) 109 | self.assertEqual( 110 | convert_real_to_text(9999999999999.99), 111 | "Nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais e noventa e nove centavos", 112 | ) 113 | 114 | # 1 quadrillion 115 | self.assertEqual( 116 | convert_real_to_text(1000000000000000.00), 117 | "Um quatrilhão de reais", 118 | ) 119 | 120 | # Edge cases should return None 121 | self.assertIsNone( 122 | convert_real_to_text("invalid_value") 123 | ) # invalid value 124 | self.assertIsNone(convert_real_to_text(None)) # None value 125 | self.assertIsNone( 126 | convert_real_to_text(-1000000000000001.00) 127 | ) # less than -1 quadrillion 128 | self.assertIsNone( 129 | convert_real_to_text(-1000000000000001.00) 130 | ) # more than 1 quadrillion 131 | self.assertIsNone(convert_real_to_text(float("inf"))) # Infinity 132 | self.assertIsNone( 133 | convert_real_to_text(float("nan")) 134 | ) # Not a number (NaN) 135 | -------------------------------------------------------------------------------- /tests/ibge/test_uf.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from brutils.ibge.uf import ( 4 | convert_code_to_uf, 5 | convert_name_to_uf, 6 | convert_uf_to_name, 7 | ) 8 | 9 | 10 | class TestUF(TestCase): 11 | def test_convert_code_to_uf_valid(self): 12 | test_cases = [ 13 | ("12", "AC"), 14 | ("27", "AL"), 15 | ("16", "AP"), 16 | ("13", "AM"), 17 | ("29", "BA"), 18 | ("23", "CE"), 19 | ("53", "DF"), 20 | ("32", "ES"), 21 | ("52", "GO"), 22 | ("21", "MA"), 23 | ("51", "MT"), 24 | ("50", "MS"), 25 | ("31", "MG"), 26 | ("15", "PA"), 27 | ("25", "PB"), 28 | ("41", "PR"), 29 | ("26", "PE"), 30 | ("22", "PI"), 31 | ("33", "RJ"), 32 | ("24", "RN"), 33 | ("43", "RS"), 34 | ("11", "RO"), 35 | ("14", "RR"), 36 | ("42", "SC"), 37 | ("35", "SP"), 38 | ("28", "SE"), 39 | ("17", "TO"), 40 | ] 41 | for code, expected_uf in test_cases: 42 | with self.subTest(code=code): 43 | self.assertEqual(convert_code_to_uf(code), expected_uf) 44 | 45 | def test_convert_code_to_uf_invalid(self): 46 | invalid_codes = ["99", "00", "", "AB", "1", "123"] 47 | for invalid_code in invalid_codes: 48 | with self.subTest(code=invalid_code): 49 | self.assertIsNone(convert_code_to_uf(invalid_code)) 50 | 51 | def test_convert_uf_to_name_valid(self): 52 | test_cases = [ 53 | ("SP", "São Paulo"), 54 | ("RJ", "Rio de Janeiro"), 55 | ("MG", "Minas Gerais"), 56 | ("DF", "Distrito Federal"), 57 | ("BA", "Bahia"), 58 | ("RS", "Rio Grande do Sul"), 59 | ] 60 | for uf, expected_name in test_cases: 61 | with self.subTest(uf=uf): 62 | self.assertEqual(convert_uf_to_name(uf), expected_name) 63 | 64 | def test_convert_uf_to_name_case_insensitive(self): 65 | test_cases = [ 66 | ("sp", "São Paulo"), 67 | ("df", "Distrito Federal"), 68 | ("Rj", "Rio de Janeiro"), 69 | ] 70 | for uf, expected_name in test_cases: 71 | with self.subTest(uf=uf): 72 | self.assertEqual(convert_uf_to_name(uf), expected_name) 73 | 74 | def test_convert_uf_to_name_invalid(self): 75 | invalid_ufs = ["XX", "XXX", "", "A", "123", " "] 76 | for invalid_uf in invalid_ufs: 77 | with self.subTest(uf=invalid_uf): 78 | self.assertIsNone(convert_uf_to_name(invalid_uf)) 79 | 80 | def test_convert_name_to_uf_all_states(self): 81 | test_cases = [ 82 | ("Acre", "AC"), 83 | ("Alagoas", "AL"), 84 | ("Amapá", "AP"), 85 | ("Amazonas", "AM"), 86 | ("Bahia", "BA"), 87 | ("Ceará", "CE"), 88 | ("Distrito Federal", "DF"), 89 | ("Espírito Santo", "ES"), 90 | ("Goiás", "GO"), 91 | ("Maranhão", "MA"), 92 | ("Mato Grosso", "MT"), 93 | ("Mato Grosso do Sul", "MS"), 94 | ("Minas Gerais", "MG"), 95 | ("Pará", "PA"), 96 | ("Paraíba", "PB"), 97 | ("Paraná", "PR"), 98 | ("Pernambuco", "PE"), 99 | ("Piauí", "PI"), 100 | ("Rio de Janeiro", "RJ"), 101 | ("Rio Grande do Norte", "RN"), 102 | ("Rio Grande do Sul", "RS"), 103 | ("Rondônia", "RO"), 104 | ("Roraima", "RR"), 105 | ("Santa Catarina", "SC"), 106 | ("São Paulo", "SP"), 107 | ("Sergipe", "SE"), 108 | ("Tocantins", "TO"), 109 | ] 110 | for state_name, expected_uf in test_cases: 111 | with self.subTest(state_name=state_name): 112 | self.assertEqual(convert_name_to_uf(state_name), expected_uf) 113 | 114 | def test_convert_name_to_uf_normalization(self): 115 | test_cases = [ 116 | ("sao paulo", "SP"), 117 | ("SAO PAULO", "SP"), 118 | ("SãO pAuLo", "SP"), 119 | (" Rio de Janeiro ", "RJ"), 120 | ("ceara", "CE"), 121 | ("PARANÁ", "PR"), 122 | ("parana", "PR"), 123 | ] 124 | for state_name, expected_uf in test_cases: 125 | with self.subTest(state_name=state_name): 126 | self.assertEqual(convert_name_to_uf(state_name), expected_uf) 127 | 128 | def test_convert_name_to_uf_wrong_spacing(self): 129 | test_cases = [ 130 | ("Sao Paulo", "SP"), 131 | ("Rio de Janeiro", "RJ"), 132 | ("Mato Grosso", "MT"), 133 | ("Rio Grande do Sul", "RS"), 134 | ("Mato Grosso do Sul", "MS"), 135 | ("Espírito Santo", "ES"), 136 | ("Rio Grande do Norte", "RN"), 137 | ] 138 | for state_name, expected_uf in test_cases: 139 | with self.subTest(state_name=state_name): 140 | self.assertEqual(convert_name_to_uf(state_name), expected_uf) 141 | 142 | def test_convert_name_to_uf_invalid_input(self): 143 | invalid_inputs = [ 144 | None, 145 | "", 146 | " ", 147 | "Estado Inválido", 148 | "São Pauloo", 149 | "Rio", 150 | "Brasil", 151 | "XYZ", 152 | 123, 153 | [], 154 | {}, 155 | ] 156 | for invalid_input in invalid_inputs: 157 | with self.subTest(input=repr(invalid_input)): 158 | self.assertIsNone(convert_name_to_uf(invalid_input)) 159 | -------------------------------------------------------------------------------- /brutils/legal_process.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | from datetime import datetime 5 | from random import randint 6 | from typing import Final 7 | 8 | ROOT_DIR: Final[str] = os.path.dirname(os.path.abspath(__file__)) 9 | DATA_DIR: Final[str] = f"{ROOT_DIR}/data" 10 | VALID_IDS_FILE: Final[str] = f"{DATA_DIR}/legal_process_ids.json" 11 | 12 | # FORMATTING 13 | ############ 14 | 15 | 16 | def remove_symbols(legal_process: str) -> str: 17 | """ 18 | Removes specific symbols from a given legal process. 19 | 20 | This function takes a legal process as input and removes all occurrences 21 | of the '.' and '-' characters from it. 22 | 23 | Args: 24 | legal_process (str): A legal process containing symbols to be removed. 25 | 26 | Returns: 27 | str: The legal process string with the specified symbols removed. 28 | 29 | Example: 30 | >>> remove_symbols("123.45-678.901.234-56.7890") 31 | '12345678901234567890' 32 | >>> remove_symbols("9876543-21.0987.6.54.3210") 33 | '98765432109876543210' 34 | """ 35 | 36 | return legal_process.replace(".", "").replace("-", "") 37 | 38 | 39 | def format_legal_process(legal_process_id: str) -> str | None: 40 | """ 41 | Format a legal process ID into a standard format. 42 | 43 | Args: 44 | legal_process_id (str): A 20-digits string representing the legal 45 | process ID. 46 | 47 | Returns: 48 | str: The formatted legal process ID or None if the input is invalid. 49 | 50 | Example: 51 | >>> format_legal_process("12345678901234567890") 52 | '1234567-89.0123.4.56.7890' 53 | >>> format_legal_process("98765432109876543210") 54 | '9876543-21.0987.6.54.3210' 55 | >>> format_legal_process("123") 56 | None 57 | """ 58 | 59 | if legal_process_id.isdigit() and len(legal_process_id) == 20: 60 | capture_fields = r"(\d{7})(\d{2})(\d{4})(\d)(\d{2})(\d{4})" 61 | include_chars = r"\1-\2.\3.\4.\5.\6" 62 | 63 | return re.sub(capture_fields, include_chars, legal_process_id) 64 | 65 | return None 66 | 67 | 68 | # OPERATIONS 69 | ############ 70 | 71 | 72 | def is_valid(legal_process_id: str) -> bool: 73 | """ 74 | Check if a legal process ID is valid. 75 | 76 | This function does not verify if the legal process ID is a real legal 77 | process ID; it only validates the format of the string. 78 | 79 | Args: 80 | legal_process_id (str): A digit-only string representing the legal 81 | process ID. 82 | 83 | Returns: 84 | bool: True if the legal process ID is valid, False otherwise. 85 | 86 | Example: 87 | >>> is_valid("68476506020233030000") 88 | True 89 | >>> is_valid("51808233620233030000") 90 | True 91 | >>> is_valid("123") 92 | False 93 | """ 94 | 95 | clean_legal_process_id = remove_symbols(legal_process_id) 96 | DD = clean_legal_process_id[7:9] 97 | J = clean_legal_process_id[13:14] 98 | TR = clean_legal_process_id[14:16] 99 | OOOO = clean_legal_process_id[16:] 100 | 101 | with open(VALID_IDS_FILE) as file: 102 | legal_process_ids = json.load(file) 103 | process = legal_process_ids.get(f"orgao_{J}") 104 | if not process: 105 | return False 106 | valid_process = int(TR) in process.get("id_tribunal") and int( 107 | OOOO 108 | ) in process.get("id_foro") 109 | 110 | return ( 111 | _checksum(int(clean_legal_process_id[0:7] + clean_legal_process_id[9:])) 112 | == DD 113 | ) and valid_process 114 | 115 | 116 | def generate( 117 | year: int = datetime.now().year, orgao: int = randint(1, 9) 118 | ) -> str | None: 119 | """ 120 | Generate a random legal process ID number. 121 | 122 | Args: 123 | year (int): The year for the legal process ID (default is the current 124 | year). 125 | The year should not be in the past 126 | orgao (int): The organization code (1-9) for the legal process ID 127 | (default is random). 128 | 129 | Returns: 130 | str: A randomly generated legal process ID. 131 | None if one of the arguments is invalid. 132 | 133 | Example: 134 | >>> generate(2023, 5) 135 | '51659517020235080562' 136 | >>> generate() 137 | '88031888120233030000' 138 | >>> generate(2022, 10) 139 | None 140 | """ 141 | 142 | if year < datetime.now().year or orgao not in range(1, 10): 143 | return None 144 | 145 | # Getting possible legal process ids from 'legal_process_ids.json' asset 146 | with open(VALID_IDS_FILE) as file: 147 | legal_process_ids = json.load(file) 148 | _ = legal_process_ids[f"orgao_{orgao}"] 149 | TR = str( 150 | _["id_tribunal"][randint(0, (len(_["id_tribunal"]) - 1))] 151 | ).zfill(2) 152 | OOOO = str(_["id_foro"][randint(0, (len(_["id_foro"])) - 1)]).zfill(4) 153 | NNNNNNN = str(randint(0, 9999999)).zfill(7) 154 | DD = _checksum(f"{NNNNNNN}{year}{orgao}{TR}{OOOO}") 155 | 156 | return f"{NNNNNNN}{DD}{year}{orgao}{TR}{OOOO}" 157 | 158 | 159 | def _checksum(basenum: int) -> str: 160 | """ 161 | Checksum to compute the verification digit for a Legal Process ID number. 162 | `basenum` needs to be a digit without the verification id. 163 | 164 | Args: 165 | basenum (int): The base number for checksum calculation. 166 | 167 | Returns: 168 | str: The checksum value as a string. 169 | 170 | Example: 171 | >>> _checksum(1234567) 172 | '50' 173 | >>> _checksum(9876543) 174 | '88' 175 | """ 176 | 177 | return str(97 - ((int(basenum) * 100) % 97)).zfill(2) 178 | -------------------------------------------------------------------------------- /brutils/ibge/municipality.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import io 3 | import json 4 | import unicodedata 5 | from urllib.error import HTTPError 6 | from urllib.request import urlopen 7 | 8 | IBGE_MUNICIPALITY_BY_CODE_URL = ( 9 | "https://servicodados.ibge.gov.br/api/v1/localidades/municipios/{code}" 10 | ) 11 | IBGE_MUNICIPALITIES_BY_UF_URL = "https://servicodados.ibge.gov.br/api/v1/localidades/estados/{uf}/municipios" 12 | 13 | 14 | def get_municipality_by_code(code: str) -> tuple[str, str] | None: 15 | """ 16 | Returns the municipality name and UF for a given IBGE code. 17 | 18 | This function takes a string representing an IBGE municipality code 19 | and returns a tuple with the municipality's name and its corresponding UF. 20 | 21 | Args: 22 | code (str): The IBGE code of the municipality. 23 | 24 | Returns: 25 | tuple: A tuple formatted as ("Município", "UF") or 26 | None if the code is not valid. 27 | 28 | Example: 29 | >>> get_municipality_by_code("3550308") 30 | ("São Paulo", "SP") 31 | >>> get_municipality_by_code("3304557") 32 | ("Rio de Janeiro", "RJ") 33 | >>> get_municipality_by_code("1234567") 34 | None 35 | """ 36 | base_url = IBGE_MUNICIPALITY_BY_CODE_URL.format(code=code) 37 | 38 | decompressed_data = _fetch_ibge_data(base_url) 39 | 40 | if decompressed_data is None: 41 | return None 42 | 43 | try: 44 | json_data = json.loads(decompressed_data) 45 | return _get_values(json_data) 46 | except (json.JSONDecodeError, KeyError): 47 | return None 48 | 49 | 50 | def get_code_by_municipality_name( 51 | municipality_name: str, uf: str 52 | ) -> str | None: 53 | """ 54 | Returns the IBGE code for a given municipality name and uf code. 55 | 56 | This function takes a string representing a municipality's name 57 | and uf's code and returns the corresponding IBGE code (string). The function 58 | will handle names by ignoring differences in case, accents, and 59 | treating the character ç as c and ignoring case differences for the uf code. 60 | 61 | Args: 62 | municipality_name (str): The name of the municipality. 63 | uf (str): The uf code of the state. 64 | 65 | Returns: 66 | str: The IBGE code of the municipality or 67 | None if the name is not valid or does not exist. 68 | 69 | Example: 70 | >>> get_code_by_municipality_name("São Paulo", "SP") 71 | "3550308" 72 | >>> get_code_by_municipality_name("Conceição do Coité", "Ba") 73 | "2908408" 74 | >>> get_code_by_municipality_name("Municipio Inexistente", "RS") 75 | None 76 | """ 77 | uf = uf.upper() 78 | 79 | base_url = IBGE_MUNICIPALITIES_BY_UF_URL.format(uf=uf) 80 | 81 | decompressed_data = _fetch_ibge_data(base_url) 82 | if decompressed_data is None: 83 | return None 84 | 85 | try: 86 | json_data = json.loads(decompressed_data) 87 | normalized_municipality_name = _transform_text(municipality_name) 88 | 89 | for municipality in json_data: 90 | municipality_name_from_api = municipality.get("nome", "") 91 | normalized_name_from_api = _transform_text( 92 | municipality_name_from_api 93 | ) 94 | 95 | if normalized_name_from_api == normalized_municipality_name: 96 | return str(municipality.get("id")) 97 | 98 | return None 99 | 100 | except (json.JSONDecodeError, KeyError): 101 | return None 102 | 103 | 104 | def _fetch_ibge_data(url: str) -> bytes | None: 105 | """ 106 | Fetch data from IBGE API with gzip decompression support. 107 | 108 | Args: 109 | url (str): The URL to fetch data from. 110 | 111 | Returns: 112 | bytes | None: The decompressed data or None if failed. 113 | """ 114 | try: 115 | with urlopen(url) as f: 116 | compressed_data = f.read() 117 | if f.info().get("Content-Encoding") == "gzip": 118 | try: 119 | with gzip.GzipFile( 120 | fileobj=io.BytesIO(compressed_data) 121 | ) as gzip_file: 122 | decompressed_data = gzip_file.read() 123 | except (OSError, Exception): 124 | return None 125 | else: 126 | decompressed_data = compressed_data 127 | 128 | if _is_empty(decompressed_data): 129 | return None 130 | 131 | return decompressed_data 132 | 133 | except HTTPError: 134 | return None 135 | except Exception: 136 | return None 137 | 138 | 139 | def _get_values(data: dict) -> tuple[str, str]: 140 | """Extract municipality name and UF from IBGE API response.""" 141 | municipio = data["nome"] 142 | estado = data["microrregiao"]["mesorregiao"]["UF"]["sigla"] 143 | return (municipio, estado) 144 | 145 | 146 | def _is_empty(data: bytes) -> bool: 147 | """Check if the response data is empty.""" 148 | return data == b"[]" or len(data) == 0 149 | 150 | 151 | def _transform_text(municipality_name: str) -> str: 152 | """ 153 | Normalize municipality name and returns the normalized string. 154 | 155 | Args: 156 | municipality_name (str): The name of the municipality. 157 | 158 | Returns: 159 | str: The normalized string 160 | 161 | Example: 162 | >>> _transform_text("São Paulo") 163 | 'sao paulo' 164 | >>> _transform_text("Goiânia") 165 | 'goiania' 166 | >>> _transform_text("Conceição do Coité") 167 | 'conceicao do coite' 168 | """ 169 | normalized_string = ( 170 | unicodedata.normalize("NFKD", municipality_name) 171 | .encode("ascii", "ignore") 172 | .decode("ascii") 173 | ) 174 | case_fold_string = normalized_string.casefold() 175 | 176 | return case_fold_string 177 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT_EN.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement via email to 64 | cmaiacd@gmail.com or mdeazevedomaia@gmail.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /tests/phone/test_is_valid.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from unittest.mock import patch 3 | 4 | from brutils.phone import ( 5 | _is_valid_landline, 6 | _is_valid_mobile, 7 | is_valid, 8 | ) 9 | 10 | 11 | @patch("brutils.phone._is_valid_landline") 12 | @patch("brutils.phone._is_valid_mobile") 13 | class TestIsValidWithTypeMobile(TestCase): 14 | def test_when_mobile_is_valid_returns_true( 15 | self, mock__is_valid_mobile, mock__is_valid_landline 16 | ): 17 | mock__is_valid_mobile.return_value = True 18 | 19 | self.assertIs(is_valid("11994029275", "mobile"), True) 20 | mock__is_valid_mobile.assert_called_once_with("11994029275") 21 | mock__is_valid_landline.assert_not_called() 22 | 23 | def test_when_mobile_is_not_valid_returns_false( 24 | self, mock__is_valid_mobile, mock__is_valid_landline 25 | ): 26 | mock__is_valid_mobile.return_value = False 27 | 28 | self.assertIs(is_valid("119940", "mobile"), False) 29 | mock__is_valid_mobile.assert_called_once_with("119940") 30 | mock__is_valid_landline.assert_not_called() 31 | 32 | 33 | @patch("brutils.phone._is_valid_mobile") 34 | @patch("brutils.phone._is_valid_landline") 35 | class TestIsValidWithTypeLandline(TestCase): 36 | def test_when_landline_is_valid_returns_true( 37 | self, mock__is_valid_landline, mock__is_valid_mobile 38 | ): 39 | mock__is_valid_landline.return_value = True 40 | 41 | self.assertIs(is_valid("11994029275", "landline"), True) 42 | mock__is_valid_landline.assert_called_once_with("11994029275") 43 | mock__is_valid_mobile.assert_not_called() 44 | 45 | def test_when_landline_is_not_valid_returns_false( 46 | self, mock__is_valid_landline, mock__is_valid_mobile 47 | ): 48 | mock__is_valid_landline.return_value = False 49 | 50 | self.assertIs(is_valid("11994029275", "landline"), False) 51 | mock__is_valid_landline.assert_called_once_with("11994029275") 52 | mock__is_valid_mobile.assert_not_called() 53 | 54 | 55 | @patch("brutils.phone._is_valid_landline") 56 | @patch("brutils.phone._is_valid_mobile") 57 | class TestIsValidWithTypeNone(TestCase): 58 | def test_when_landline_is_valid( 59 | self, mock__is_valid_mobile, mock__is_valid_landline 60 | ): 61 | mock__is_valid_landline.return_value = True 62 | 63 | self.assertIs(is_valid("1958814933"), True) 64 | mock__is_valid_landline.assert_called_once_with("1958814933") 65 | mock__is_valid_mobile.assert_not_called() 66 | 67 | def test_when_landline_invalid_mobile_valid( 68 | self, mock__is_valid_mobile, mock__is_valid_landline 69 | ): 70 | mock__is_valid_landline.return_value = False 71 | mock__is_valid_mobile.return_value = True 72 | 73 | self.assertIs(is_valid("11994029275"), True) 74 | mock__is_valid_landline.assert_called_once_with("11994029275") 75 | mock__is_valid_mobile.assert_called_once_with("11994029275") 76 | 77 | def test_when_landline_and_mobile_are_invalid( 78 | self, mock__is_valid_mobile, mock__is_valid_landline 79 | ): 80 | mock__is_valid_landline.return_value = False 81 | mock__is_valid_mobile.return_value = False 82 | 83 | self.assertIs(is_valid("11994029275"), False) 84 | mock__is_valid_landline.assert_called_once_with("11994029275") 85 | mock__is_valid_mobile.assert_called_once_with("11994029275") 86 | 87 | 88 | class TestIsValidLandline(TestCase): 89 | def test__is_valid_landline(self): 90 | # When landline phone is not string, returns False 91 | self.assertIs(_is_valid_landline(1938814933), False) 92 | 93 | # When landline phone doesn't contain only digits, returns False 94 | self.assertIs(_is_valid_landline("(19)388149"), False) 95 | 96 | # When landline phone is an empty string, returns False 97 | self.assertIs(_is_valid_landline(""), False) 98 | 99 | # When landline phone's len is different of 10, returns False 100 | self.assertIs(_is_valid_landline("193881"), False) 101 | 102 | # When landline phone's first digit is 0, returns False 103 | self.assertIs(_is_valid_landline("0938814933"), False) 104 | 105 | # When landline phone's second digit is 0, returns False 106 | self.assertIs(_is_valid_landline("1038814933"), False) 107 | 108 | # When landline phone's third digit is different of 2,3,4 or 5, 109 | # returns False 110 | self.assertIs(_is_valid_landline("1998814933"), False) 111 | 112 | # When landline phone is valid 113 | self.assertIs(_is_valid_landline("1928814933"), True) 114 | self.assertIs(_is_valid_landline("1938814933"), True) 115 | self.assertIs(_is_valid_landline("1948814933"), True) 116 | self.assertIs(_is_valid_landline("1958814933"), True) 117 | self.assertIs(_is_valid_landline("3333333333"), True) 118 | 119 | 120 | class TestIsValidMobile(TestCase): 121 | def test__is_valid_mobile(self): 122 | # When mobile is not string, returns False 123 | self.assertIs(_is_valid_mobile(1), False) 124 | 125 | # When mobile doesn't contain only digits, returns False 126 | self.assertIs(_is_valid_mobile(119940 - 2927), False) 127 | 128 | # When mobile is an empty string, returns False 129 | self.assertIs(_is_valid_mobile(""), False) 130 | 131 | # When mobile's len is different of 11, returns False 132 | self.assertIs(_is_valid_mobile("119940"), False) 133 | 134 | # When mobile's first digit is 0, returns False 135 | self.assertIs(_is_valid_mobile("01994029275"), False) 136 | 137 | # When mobile's second digit is 0, returns False 138 | self.assertIs(_is_valid_mobile("90994029275"), False) 139 | 140 | # When mobile's third digit is different of 9, returns False 141 | self.assertIs(_is_valid_mobile("11594029275"), False) 142 | 143 | # When mobile is valid 144 | self.assertIs(_is_valid_mobile("99999999999"), True) 145 | self.assertIs(_is_valid_mobile("11994029275"), True) 146 | 147 | 148 | if __name__ == "__main__": 149 | main() 150 | -------------------------------------------------------------------------------- /brutils/phone.py: -------------------------------------------------------------------------------- 1 | import re 2 | from random import choice, randint 3 | 4 | 5 | # FORMATTING 6 | ############ 7 | def format_phone(phone: str) -> str | None: 8 | """ 9 | Function responsible for formatting a telephone number 10 | 11 | Args: 12 | phone_number (str): The phone number to format. 13 | 14 | Returns: 15 | str: The formatted phone number, or None if the number is not valid. 16 | 17 | 18 | >>> format_phone("11994029275") 19 | '(11)99402-9275' 20 | >>> format_phone("1635014415") 21 | '(16)3501-4415' 22 | >>> format_phone("333333") 23 | """ 24 | if not is_valid(phone): 25 | return None 26 | 27 | ddd = phone[:2] 28 | phone_number = phone[2:] 29 | 30 | return f"({ddd}){phone_number[:-4]}-{phone_number[-4:]}" 31 | 32 | 33 | # OPERATIONS 34 | ############ 35 | 36 | 37 | def is_valid(phone_number: str, type: str = None) -> bool: 38 | """ 39 | Returns if a Brazilian phone number is valid. 40 | It does not verify if the number actually exists. 41 | 42 | Args: 43 | phone_number (str): The phone number to validate. 44 | Only digits, without country code. 45 | It should include two digits DDD. 46 | type (str): "mobile" or "landline". 47 | If not specified, checks for one or another. 48 | 49 | Returns: 50 | bool: True if the phone number is valid. False otherwise. 51 | """ 52 | 53 | if type == "landline": 54 | return _is_valid_landline(phone_number) 55 | if type == "mobile": 56 | return _is_valid_mobile(phone_number) 57 | 58 | return _is_valid_landline(phone_number) or _is_valid_mobile(phone_number) 59 | 60 | 61 | def remove_symbols_phone(phone_number: str) -> str: 62 | """ 63 | Removes common symbols from a Brazilian phone number string. 64 | 65 | Args: 66 | phone_number (str): The phone number to remove symbols. 67 | Can include two digits DDD. 68 | 69 | Returns: 70 | str: A new string with the specified symbols removed. 71 | """ 72 | 73 | cleaned_phone = ( 74 | phone_number.replace("(", "") 75 | .replace(")", "") 76 | .replace("-", "") 77 | .replace("+", "") 78 | .replace(" ", "") 79 | ) 80 | return cleaned_phone 81 | 82 | 83 | def generate(type: str = None) -> str: 84 | """ 85 | Generate a valid and random phone number. 86 | 87 | Args: 88 | type (str): "landline" or "mobile". 89 | If not specified, checks for one or another. 90 | 91 | Returns: 92 | str: A randomly generated valid phone number. 93 | 94 | Example: 95 | >>> generate() 96 | "2234451215" 97 | >>> generate("mobile") 98 | "1899115895" 99 | >>> generate("landline") 100 | "5535317900" 101 | """ 102 | 103 | if type == "mobile": 104 | return _generate_mobile_phone() 105 | 106 | if type == "landline": 107 | return _generate_landline_phone() 108 | 109 | generate_functions = [_generate_landline_phone, _generate_mobile_phone] 110 | return choice(generate_functions)() 111 | 112 | 113 | def remove_international_dialing_code(phone_number: str) -> str: 114 | """ 115 | Function responsible for remove a international code phone 116 | 117 | Args: 118 | phone_number (str): The phone number with international code phone. 119 | 120 | Returns: 121 | str: The phone number without international code 122 | or the same phone number. 123 | 124 | Example: 125 | >>> remove_international_dialing_code("5511994029275") 126 | '11994029275' 127 | >>> remove_international_dialing_code("1635014415") 128 | '1635014415' 129 | >>> remove_international_dialing_code("+5511994029275") 130 | '+11994029275' 131 | """ 132 | 133 | pattern = r"\+?55" 134 | 135 | if ( 136 | re.search(pattern, phone_number) 137 | and len(phone_number.replace(" ", "")) > 11 138 | ): 139 | return phone_number.replace("55", "", 1) 140 | else: 141 | return phone_number 142 | 143 | 144 | def _is_valid_mobile(phone_number: str) -> bool: 145 | """ 146 | Returns if a Brazilian mobile number is valid. 147 | It does not verify if the number actually exists. 148 | 149 | Args: 150 | phone_number (str): The mobile number to validate. 151 | Only digits, without country code. 152 | It should include two digits DDD. 153 | 154 | Returns: 155 | bool: True if the phone number is valid. False otherwise. 156 | """ 157 | 158 | pattern = re.compile(r"^[1-9][1-9][9]\d{8}$") 159 | return ( 160 | isinstance(phone_number, str) 161 | and re.match(pattern, phone_number) is not None 162 | ) 163 | 164 | 165 | def _is_valid_landline(phone_number: str) -> bool: 166 | """ 167 | Returns if a Brazilian landline number is valid. 168 | It does not verify if the number actually exists. 169 | 170 | Args: 171 | phone_number (str): The landline number to validate. 172 | Only digits, without country code. 173 | It should include two digits DDD. 174 | 175 | Returns: 176 | bool: True if the phone number is valid. False otherwise. 177 | """ 178 | 179 | pattern = re.compile(r"^[1-9][1-9][2-5]\d{7}$") 180 | return ( 181 | isinstance(phone_number, str) 182 | and re.match(pattern, phone_number) is not None 183 | ) 184 | 185 | 186 | def _generate_ddd_number() -> str: 187 | """ 188 | Generate a valid DDD number. 189 | """ 190 | return f'{"".join([str(randint(1, 9)) for i in range(2)])}' 191 | 192 | 193 | def _generate_mobile_phone() -> str: 194 | """ 195 | Generate a valid and random mobile phone number 196 | """ 197 | ddd = _generate_ddd_number() 198 | client_number = [str(randint(0, 9)) for i in range(8)] 199 | 200 | phone_number = f'{ddd}9{"".join(client_number)}' 201 | 202 | return phone_number 203 | 204 | 205 | def _generate_landline_phone() -> str: 206 | """ 207 | Generate a valid and random landline phone number. 208 | """ 209 | ddd = _generate_ddd_number() 210 | return f"{ddd}{randint(2,5)}{str(randint(0,9999999)).zfill(7)}" 211 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Código de Conduta de Colaboração 3 | 4 | ## Nosso compromisso 5 | 6 | Como participantes, colaboradoras e líderes, nós nos comprometemos a fazer com que a participação 7 | em nossa comunidade seja uma experiência livre de assédio para todas as pessoas, independentemente 8 | de idade, tamanho do corpo, deficiência aparente ou não aparente, etnia, características sexuais, 9 | identidade ou expressão de gênero, nível de experiência, educação, situação sócio-econômica, 10 | nacionalidade, aparência pessoal, raça, casta, religião ou identidade e orientação sexuais. 11 | 12 | Comprometemo-nos a agir e interagir de maneiras que contribuam para uma comunidade aberta, 13 | acolhedora, diversificada, inclusiva e saudável. 14 | 15 | ## Nossos padrões 16 | 17 | Exemplos de comportamentos que contribuem para criar um ambiente positivo para a nossa comunidade 18 | incluem: 19 | 20 | * Demonstrar empatia e bondade com as outras pessoas 21 | * Respeitar opiniões, pontos de vista e experiências contrárias 22 | * Dar e receber feedbacks construtivos de maneira respeitosa 23 | * Assumir responsabilidade, pedir desculpas às pessoas afetadas por nossos erros e aprender com a 24 | experiência 25 | * Focar no que é melhor não só para nós individualmente, mas para a comunidade em geral 26 | 27 | Exemplos de comportamentos inaceitáveis incluem: 28 | 29 | * Uso de linguagem ou imagens sexualizadas, bem como o assédio sexual ou de qualquer natureza 30 | * Comentários insultuosos/depreciativos e ataques pessoais ou políticos (Trolling) 31 | * Assédio público ou privado 32 | * Publicar informações particulares de outras pessoas, como um endereço de e-mail ou endereço 33 | físico, sem a permissão explícita delas 34 | * Outras condutas que são normalmente consideradas inapropriadas em um ambiente profissional 35 | 36 | ## Aplicação das nossas responsabilidades 37 | 38 | A liderança da comunidade é responsável por esclarecer e aplicar nossos padrões de comportamento 39 | aceitáveis e tomará ações corretivas apropriadas e justas em resposta a qualquer comportamento que 40 | considerar impróprio, ameaçador, ofensivo ou problemático. 41 | 42 | A liderança da comunidade tem o direito e a responsabilidade de remover, editar ou rejeitar 43 | comentários, commits, códigos, edições na wiki, erros e outras contribuições que não estão 44 | alinhadas com este Código de Conduta e irá comunicar as razões por trás das decisões da moderação 45 | quando for apropriado. 46 | 47 | ## Escopo 48 | 49 | Este Código de Conduta se aplica dentro de todos os espaços da comunidade e também se aplica quando 50 | uma pessoa estiver representando oficialmente a comunidade em espaços públicos. Exemplos de 51 | representação da nossa comunidade incluem usar um endereço de e-mail oficial, postar em contas 52 | oficiais de mídias sociais ou atuar como uma pessoa indicada como representante em um evento 53 | online ou offline. 54 | 55 | ## Aplicação 56 | 57 | Ocorrências de comportamentos abusivos, de assédio ou que sejam inaceitáveis por qualquer outro 58 | motivo poderão ser reportadas para a liderança da comunidade, responsável pela aplicação, via 59 | contato cmaiacd@gmail.com ou mdeazevedomaia@gmail.com. Todas as reclamações serão revisadas e 60 | investigadas imediatamente e de maneira justa. 61 | 62 | A liderança da comunidade tem a obrigação de respeitar a privacidade e a segurança de quem reportar 63 | qualquer incidente. 64 | 65 | ## Diretrizes de aplicação 66 | 67 | A liderança da comunidade seguirá estas Diretrizes de Impacto na Comunidade para determinar as 68 | consequências de qualquer ação que considerar violadora deste Código de Conduta: 69 | 70 | ### 1. Ação Corretiva 71 | 72 | **Impacto na comunidade**: Uso de linguagem imprópria ou outro comportamento considerado 73 | anti-profissional ou repudiado pela comunidade. 74 | 75 | **Consequência**: Aviso escrito e privado da liderança da comunidade, esclarecendo a natureza da 76 | violação e com a explicação do motivo pelo qual o comportamento era impróprio. Um pedido de 77 | desculpas público poderá ser solicitado. 78 | 79 | ### 2. Advertência 80 | 81 | **Impacto na comunidade**: Violação por meio de um incidente único ou atitudes repetidas. 82 | 83 | **Consequência**: Advertência com consequências para comportamento repetido. Não poderá haver 84 | interações com as pessoas envolvidas, incluindo interações não solicitadas com as pessoas que 85 | estiverem aplicando o Código de Conduta, por um período determinado. Isto inclui evitar interações 86 | em espaços da comunidade, bem como canais externos como as mídias sociais. A violação destes termos 87 | pode levar a um banimento temporário ou permanente. 88 | 89 | ### 3. Banimento Temporário 90 | 91 | **Impacto na comunidade**: Violação grave dos padrões da comunidade, incluindo a persistência do 92 | comportamento impróprio. 93 | 94 | **Consequência**: Banimento temporário de qualquer tipo de interação ou comunicação pública com a 95 | comunidade por um determinado período. Estarão proibidas as interações públicas ou privadas com as 96 | pessoas envolvidas, incluindo interações não solicitadas com as pessoas que estiverem aplicando o 97 | Código de Conduta. A violação destes termos pode resultar em um banimento permanente. 98 | 99 | ### 4. Banimento Permanente 100 | 101 | **Impacto na comunidade**: Demonstrar um padrão na violação das normas da comunidade, incluindo a 102 | persistência do comportamento impróprio, assédio a uma pessoa ou agressão ou depreciação a classes 103 | de pessoas. 104 | 105 | **Consequência**: Banimento permanente de qualquer tipo de interação pública dentro da comunidade. 106 | 107 | ## Atribuição 108 | 109 | Este Código de Conduta é adaptado do [Contributor Covenant][homepage], versão 2.1, disponível em 110 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 111 | 112 | As Diretrizes de Impacto na Comunidade foram inspiradas pela 113 | [Aplicação do código de conduta Mozilla][Mozilla CoC]. 114 | 115 | Para obter respostas a perguntas comuns sobre este código de conduta, veja a página de Perguntas 116 | Frequentes (FAQ) em [https://www.contributor-covenant.org/faq][FAQ]. Traduções estão disponíveis em 117 | [https://www.contributor-covenant.org/translations][translations]. 118 | 119 | [homepage]: https://www.contributor-covenant.org 120 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 121 | [Mozilla CoC]: https://github.com/mozilla/diversity 122 | [FAQ]: https://www.contributor-covenant.org/faq 123 | [translations]: https://www.contributor-covenant.org/translations 124 | -------------------------------------------------------------------------------- /brutils/legal_nature.py: -------------------------------------------------------------------------------- 1 | """ 2 | brutils.legal_nature 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | Utilities for consulting and validating the official 6 | *Natureza Jurídica* (Legal Nature) codes defined by the 7 | Receita Federal do Brasil (RFB). 8 | 9 | .. note:: 10 | The codes and descriptions in this module are sourced from the 11 | official **Tabela de Natureza Jurídica** (RFB), as provided in the 12 | document used by the Cadastro Nacional (e.g., FCN). 13 | 14 | This module offers simple lookups and validation helpers based on 15 | the official table. It does not infer the current legal/registration 16 | status of any entity. 17 | 18 | Source: https://www.gov.br/empresas-e-negocios/pt-br/drei/links-e-downloads/arquivos/TABELADENATUREZAJURDICA.pdf 19 | """ 20 | 21 | from typing import Dict, Optional 22 | 23 | # FORMATTING 24 | ############ 25 | 26 | 27 | # Helper to normalize inputs like "101-5" => "1015" 28 | def _normalize(code: str) -> Optional[str]: 29 | if not isinstance(code, str): 30 | return None 31 | 32 | digits = "".join(ch for ch in code.strip() if ch.isdigit()) 33 | 34 | return digits if len(digits) == 4 else None 35 | 36 | 37 | LEGAL_NATURE: Dict[str, str] = { 38 | # 1. ADMINISTRAÇÃO PÚBLICA 39 | "1015": "Órgão Público do Poder Executivo Federal", 40 | "1023": "Órgão Público do Poder Executivo Estadual ou do Distrito Federal", 41 | "1031": "Órgão Público do Poder Executivo Municipal", 42 | "1040": "Órgão Público do Poder Legislativo Federal", 43 | "1058": "Órgão Público do Poder Legislativo Estadual ou do Distrito Federal", 44 | "1066": "Órgão Público do Poder Legislativo Municipal", 45 | "1074": "Órgão Público do Poder Judiciário Federal", 46 | "1082": "Órgão Público do Poder Judiciário Estadual", 47 | "1104": "Autarquia Federal", 48 | "1112": "Autarquia Estadual ou do Distrito Federal", 49 | "1120": "Autarquia Municipal", 50 | "1139": "Fundação Federal", 51 | "1147": "Fundação Estadual ou do Distrito Federal", 52 | "1155": "Fundação Municipal", 53 | "1163": "Órgão Público Autônomo da União", 54 | "1171": "Órgão Público Autônomo Estadual ou do Distrito Federal", 55 | "1180": "Órgão Público Autônomo Municipal", 56 | # 2. ENTIDADES EMPRESARIAIS 57 | "2011": "Empresa Pública", 58 | "2038": "Sociedade de Economia Mista", 59 | "2046": "Sociedade Anônima Aberta", 60 | "2054": "Sociedade Anônima Fechada", 61 | "2062": "Sociedade Empresária Limitada", 62 | "2076": "Sociedade Empresária em Nome Coletivo", 63 | "2089": "Sociedade Empresária em Comandita Simples", 64 | "2097": "Sociedade Empresária em Comandita por Ações", 65 | "2100": "Sociedade Mercantil de Capital e Indústria (extinta pelo NCC/2002)", 66 | "2127": "Sociedade Empresária em Conta de Participação", 67 | "2135": "Empresário (Individual)", 68 | "2143": "Cooperativa", 69 | "2151": "Consórcio de Sociedades", 70 | "2160": "Grupo de Sociedades", 71 | "2178": "Estabelecimento, no Brasil, de Sociedade Estrangeira", 72 | "2194": "Estabelecimento, no Brasil, de Empresa Binacional Argentino-Brasileira", 73 | "2208": "Entidade Binacional Itaipu", 74 | "2216": "Empresa Domiciliada no Exterior", 75 | "2224": "Clube/Fundo de Investimento", 76 | "2232": "Sociedade Simples Pura", 77 | "2240": "Sociedade Simples Limitada", 78 | "2259": "Sociedade em Nome Coletivo", 79 | "2267": "Sociedade em Comandita Simples", 80 | "2275": "Sociedade Simples em Conta de Participação", 81 | "2305": "Empresa Individual de Responsabilidade Limitada", 82 | # 3. ENTIDADES SEM FINS LUCRATIVOS 83 | "3034": "Serviço Notarial e Registral (Cartório)", 84 | "3042": "Organização Social", 85 | "3050": "Organização da Sociedade Civil de Interesse Público (Oscip)", 86 | "3069": "Outras Formas de Fundações Mantidas com Recursos Privados", 87 | "3077": "Serviço Social Autônomo", 88 | "3085": "Condomínio Edilícios", 89 | "3093": "Unidade Executora (Programa Dinheiro Direto na Escola)", 90 | "3107": "Comissão de Conciliação Prévia", 91 | "3115": "Entidade de Mediação e Arbitragem", 92 | "3123": "Partido Político", 93 | "3131": "Entidade Sindical", 94 | "3204": "Estabelecimento, no Brasil, de Fundação ou Associação Estrangeiras", 95 | "3212": "Fundação ou Associação Domiciliada no Exterior", 96 | "3999": "Outras Formas de Associação", 97 | # 4. PESSOAS FÍSICAS 98 | "4014": "Empresa Individual Imobiliária", 99 | "4022": "Segurado Especial", 100 | "4081": "Contribuinte individual", 101 | # 5. ORGANIZAÇÕES INTERNACIONAIS E OUTRAS INSTITUIÇÕES EXTRATERRITORIAIS 102 | "5002": "Organização Internacional e Outras Instituições Extraterritoriais", 103 | } 104 | 105 | # OPERATIONS 106 | ############ 107 | 108 | 109 | def is_valid(code: str) -> bool: 110 | """ 111 | Check if a string corresponds to a valid *Natureza Jurídica* code. 112 | 113 | Args: 114 | code (str): The code to be validated. Accepts either "NNNN" or "NNN-N". 115 | 116 | Returns: 117 | bool: True if the normalized code exists in the official table, False otherwise. 118 | 119 | Example: 120 | >>> is_valid("2062") 121 | True 122 | >>> is_valid("206-2") 123 | True 124 | >>> is_valid("9999") 125 | False 126 | 127 | .. note:: 128 | Validation is based solely on the presence of the code in the 129 | official RFB table. It does not verify the current legal status 130 | or registration of the entity. 131 | """ 132 | normalized = _normalize(code) 133 | return normalized in LEGAL_NATURE if normalized else False 134 | 135 | 136 | def get_description(code: str) -> Optional[str]: 137 | """ 138 | Retrieve the description of a *Natureza Jurídica* code. 139 | 140 | Args: 141 | code (str): The code to look up. Accepts either "NNNN" or "NNN-N". 142 | 143 | Returns: 144 | str | None: The full description if the code is valid, otherwise None. 145 | 146 | Example: 147 | >>> get_description("2062") 148 | 'Sociedade Empresária Limitada' 149 | >>> get_description("101-5") 150 | 'Órgão Público do Poder Executivo Federal' 151 | >>> get_description("0000") 152 | None 153 | """ 154 | normalized = _normalize(code) 155 | return LEGAL_NATURE.get(normalized) if normalized else None 156 | 157 | 158 | def list_all() -> Dict[str, str]: 159 | """ 160 | Return a copy of the full *Natureza Jurídica* table. 161 | 162 | Returns: 163 | dict[str, str]: Mapping from "NNNN" codes to descriptions. 164 | """ 165 | return dict(LEGAL_NATURE) 166 | -------------------------------------------------------------------------------- /tests/license_plate/test_is_valid.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from unittest.mock import patch 3 | 4 | from brutils.license_plate import ( 5 | _is_valid_mercosul, 6 | _is_valid_old_format, 7 | is_valid, 8 | ) 9 | 10 | 11 | @patch("brutils.license_plate._is_valid_mercosul") 12 | @patch("brutils.license_plate._is_valid_old_format") 13 | class TestIsValidWithTypeOldFormat(TestCase): 14 | def test_when_old_format_is_valid_returns_true( 15 | self, mock__is_valid_old_format, mock__is_valid_mercosul 16 | ): 17 | mock__is_valid_old_format.return_value = True 18 | 19 | self.assertIs(is_valid("ABC1234", "old_format"), True) 20 | mock__is_valid_old_format.assert_called_once_with("ABC1234") 21 | mock__is_valid_mercosul.assert_not_called() 22 | 23 | def test_when_old_format_is_not_valid_returns_false( 24 | self, mock__is_valid_old_format, mock__is_valid_mercosul 25 | ): 26 | mock__is_valid_old_format.return_value = False 27 | 28 | self.assertIs(is_valid("123456", "old_format"), False) 29 | mock__is_valid_old_format.assert_called_once_with("123456") 30 | mock__is_valid_mercosul.assert_not_called() 31 | 32 | 33 | @patch("brutils.license_plate._is_valid_mercosul") 34 | @patch("brutils.license_plate._is_valid_old_format") 35 | class TestIsValidWithTypeMercosul(TestCase): 36 | def test_when_mercosul_is_valid_returns_true( 37 | self, mock__is_valid_old_format, mock__is_valid_mercosul 38 | ): 39 | mock__is_valid_mercosul.return_value = True 40 | 41 | self.assertIs(is_valid("ABC4E67", "mercosul"), True) 42 | mock__is_valid_mercosul.assert_called_once_with("ABC4E67") 43 | mock__is_valid_old_format.assert_not_called() 44 | 45 | def test_when_mercosul_is_not_valid_returns_false( 46 | self, mock__is_valid_old_format, mock__is_valid_mercosul 47 | ): 48 | mock__is_valid_mercosul.return_value = False 49 | 50 | self.assertIs(is_valid("11994029275", "mercosul"), False) 51 | mock__is_valid_mercosul.assert_called_once_with("11994029275") 52 | mock__is_valid_old_format.assert_not_called() 53 | 54 | 55 | @patch("brutils.license_plate._is_valid_mercosul") 56 | @patch("brutils.license_plate._is_valid_old_format") 57 | class TestIsValidWithTypeNone(TestCase): 58 | def test_when_mercosul_valid_old_format_invalid( 59 | self, mock__is_valid_old_format, mock__is_valid_mercosul 60 | ): 61 | mock__is_valid_mercosul.return_value = True 62 | mock__is_valid_old_format.return_value = False 63 | 64 | self.assertIs(is_valid("ABC4E67"), True) 65 | mock__is_valid_old_format.assert_called_once_with("ABC4E67") 66 | mock__is_valid_mercosul.assert_called_once_with("ABC4E67") 67 | 68 | def test_when_mercosul_and_old_format_are_valids( 69 | self, mock__is_valid_old_format, mock__is_valid_mercosul 70 | ): 71 | mock__is_valid_mercosul.return_value = True 72 | mock__is_valid_old_format.return_value = True 73 | 74 | self.assertIs(is_valid("ABC1234"), True) 75 | mock__is_valid_mercosul.assert_not_called() 76 | mock__is_valid_old_format.assert_called_once_with("ABC1234") 77 | 78 | def test_when_mercosul_invalid_old_format_valid( 79 | self, mock__is_valid_old_format, mock__is_valid_mercosul 80 | ): 81 | mock__is_valid_mercosul.return_value = False 82 | mock__is_valid_old_format.return_value = True 83 | 84 | self.assertIs(is_valid("ABC1234"), True) 85 | mock__is_valid_old_format.assert_called_once_with("ABC1234") 86 | mock__is_valid_mercosul.assert_not_called() 87 | 88 | def test_when_mercosul_and_old_format_are_invalid( 89 | self, mock__is_valid_old_format, mock__is_valid_mercosul 90 | ): 91 | mock__is_valid_old_format.return_value = False 92 | mock__is_valid_mercosul.return_value = False 93 | 94 | self.assertIs(is_valid("ABC1234"), False) 95 | mock__is_valid_old_format.assert_called_once_with("ABC1234") 96 | mock__is_valid_mercosul.assert_called_once_with("ABC1234") 97 | 98 | 99 | class TestIsValidOldFormat(TestCase): 100 | def test__is_valid_old_format(self): 101 | # When license plate is valid, returns True 102 | self.assertIs(_is_valid_old_format("ABC1234"), True) 103 | self.assertIs(_is_valid_old_format("abc1234"), True) 104 | 105 | # When license plate is valid with whitespaces, returns True 106 | self.assertIs(_is_valid_old_format(" ABC1234 "), True) 107 | 108 | # When license plate is not string, returns False 109 | self.assertIs(_is_valid_old_format(123456), False) 110 | 111 | # When license plate is invalid with special characters, 112 | # returns False 113 | self.assertIs(_is_valid_old_format("ABC-1234"), False) 114 | 115 | # When license plate is invalid with numbers and letters out of 116 | # order, returns False 117 | self.assertIs(_is_valid_old_format("A1CA23W"), False) 118 | 119 | # When license plate is invalid with new format, returns False 120 | self.assertIs(_is_valid_old_format("ABC1D23"), False) 121 | self.assertIs(_is_valid_old_format("abcd123"), False) 122 | 123 | 124 | class TestIsValidMercosul(TestCase): 125 | def test__is_valid_mercosul(self): 126 | # When license plate is not string, returns False 127 | self.assertIs(_is_valid_mercosul(1234567), False) 128 | 129 | # When license plate doesn't match the pattern LLLNLNN, 130 | # returns False 131 | self.assertIs(_is_valid_mercosul("ABCDEFG"), False) 132 | self.assertIs(_is_valid_mercosul("1234567"), False) 133 | self.assertIs(_is_valid_mercosul("ABC4567"), False) 134 | self.assertIs(_is_valid_mercosul("ABCD567"), False) 135 | self.assertIs(_is_valid_mercosul("ABC45F7"), False) 136 | self.assertIs(_is_valid_mercosul("ABC456G"), False) 137 | self.assertIs(_is_valid_mercosul("ABC123"), False) 138 | 139 | # When license plate is an empty string, returns False 140 | self.assertIs(_is_valid_mercosul(""), False) 141 | 142 | # When license plate's length is different of 7, returns False 143 | self.assertIs(_is_valid_mercosul("ABC4E678"), False) 144 | 145 | # When license plate has separator, returns false 146 | self.assertIs(_is_valid_mercosul("ABC-1D23"), False) 147 | 148 | # When license plate is valid 149 | self.assertIs(_is_valid_mercosul("ABC4E67"), True) 150 | self.assertIs(_is_valid_mercosul("AAA1A11"), True) 151 | self.assertIs(_is_valid_mercosul("XXX9X99"), True) 152 | 153 | # Check if function is case insensitive 154 | self.assertIs(_is_valid_mercosul("abc4e67"), True) 155 | 156 | 157 | if __name__ == "__main__": 158 | main() 159 | -------------------------------------------------------------------------------- /brutils/cpf.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | # FORMATTING 4 | ############ 5 | 6 | 7 | def sieve(dirty: str) -> str: 8 | """ 9 | Removes specific symbols from a CPF (Brazilian Individual Taxpayer Number) 10 | string. 11 | 12 | This function takes a CPF string as input and removes all occurrences of 13 | the '.', '-' characters from it. 14 | 15 | Args: 16 | cpf (str): The CPF string containing symbols to be removed. 17 | 18 | Returns: 19 | str: A new string with the specified symbols removed. 20 | 21 | Example: 22 | >>> sieve("123.456.789-01") 23 | '12345678901' 24 | >>> sieve("987-654-321.01") 25 | '98765432101' 26 | 27 | .. note:: 28 | This method should not be used in new code and is only provided for 29 | backward compatibility. 30 | """ 31 | 32 | return "".join(filter(lambda char: char not in ".-", dirty)) 33 | 34 | 35 | def remove_symbols(dirty: str) -> str: 36 | """ 37 | Alias for the `sieve` function. Better naming. 38 | 39 | Args: 40 | cpf (str): The CPF string containing symbols to be removed. 41 | 42 | Returns: 43 | str: A new string with the specified symbols removed. 44 | """ 45 | 46 | return sieve(dirty) 47 | 48 | 49 | def display(cpf: str) -> str: 50 | """ 51 | Format a CPF for display with visual aid symbols. 52 | 53 | This function takes a numbers-only CPF string as input and adds standard 54 | formatting visual aid symbols for display. 55 | 56 | Args: 57 | cpf (str): A numbers-only CPF string. 58 | 59 | Returns: 60 | str: A formatted CPF string with standard visual aid symbols 61 | or None if the input is invalid. 62 | 63 | Example: 64 | >>> display("12345678901") 65 | "123.456.789-01" 66 | >>> display("98765432101") 67 | "987.654.321-01" 68 | 69 | .. note:: 70 | This method should not be used in new code and is only provided for 71 | backward compatibility. 72 | """ 73 | 74 | if not cpf.isdigit() or len(cpf) != 11 or len(set(cpf)) == 1: 75 | return None 76 | 77 | return "{}.{}.{}-{}".format(cpf[:3], cpf[3:6], cpf[6:9], cpf[9:]) 78 | 79 | 80 | def format_cpf(cpf: str) -> str: 81 | """ 82 | Format a CPF for display with visual aid symbols. 83 | 84 | This function takes a numbers-only CPF string as input and adds standard 85 | formatting visual aid symbols for display. 86 | 87 | Args: 88 | cpf (str): A numbers-only CPF string. 89 | 90 | Returns: 91 | str: A formatted CPF string with standard visual aid symbols or None 92 | if the input is invalid. 93 | 94 | Example: 95 | >>> format_cpf("82178537464") 96 | '821.785.374-64' 97 | >>> format_cpf("55550207753") 98 | '555.502.077-53' 99 | """ 100 | 101 | if not is_valid(cpf): 102 | return None 103 | 104 | return "{}.{}.{}-{}".format(cpf[:3], cpf[3:6], cpf[6:9], cpf[9:11]) 105 | 106 | 107 | # OPERATIONS 108 | ############ 109 | 110 | 111 | def validate(cpf: str) -> bool: 112 | """ 113 | Validate the checksum digits of a CPF. 114 | 115 | This function checks whether the verifying checksum digits of the given CPF 116 | match its base number. The input should be a digit string of the proper 117 | length. 118 | 119 | Args: 120 | cpf (str): A numbers-only CPF string. 121 | 122 | Returns: 123 | bool: True if the checksum digits are valid, False otherwise. 124 | 125 | Example: 126 | >>> validate("82178537464") 127 | True 128 | >>> validate("55550207753") 129 | True 130 | 131 | .. note:: 132 | This method should not be used in new code and is only provided for 133 | backward compatibility. 134 | """ 135 | 136 | if not cpf.isdigit() or len(cpf) != 11 or len(set(cpf)) == 1: 137 | return False 138 | 139 | return all(_hashdigit(cpf, i + 10) == int(v) for i, v in enumerate(cpf[9:])) 140 | 141 | 142 | def is_valid(cpf: str) -> bool: 143 | """ 144 | Returns whether or not the verifying checksum digits of the given `˜CPF` 145 | match its base number. 146 | 147 | This function does not verify the existence of the CPF; it only 148 | validates the format of the string. 149 | 150 | Args: 151 | cpf (str): The CPF to be validated, a 11-digit string 152 | 153 | Returns: 154 | bool: True if the checksum digits match the base number, 155 | False otherwise. 156 | 157 | Example: 158 | >>> is_valid("82178537464") 159 | True 160 | >>> is_valid("55550207753") 161 | True 162 | """ 163 | 164 | return isinstance(cpf, str) and validate(cpf) 165 | 166 | 167 | def generate() -> str: 168 | """ 169 | Generate a random valid CPF digit string. 170 | 171 | This function generates a random valid CPF string. 172 | 173 | Returns: 174 | str: A random valid CPF string. 175 | 176 | Example: 177 | >>> generate() 178 | "10895948109" 179 | >>> generate() 180 | "52837606502" 181 | """ 182 | 183 | base = str(randint(1, 999999998)).zfill(9) 184 | 185 | return base + _checksum(base) 186 | 187 | 188 | def _hashdigit(cpf: str, position: int) -> int: 189 | """ 190 | Compute the given position checksum digit for a CPF. 191 | 192 | This function computes the specified position checksum digit for the CPF 193 | input. 194 | The input needs to contain all elements previous to the position, or the 195 | computation will yield the wrong result. 196 | 197 | Args: 198 | cpf (str): A CPF string. 199 | position (int): The position to calculate the checksum digit for. 200 | 201 | Returns: 202 | int: The calculated checksum digit. 203 | 204 | Example: 205 | >>> _hashdigit("52599927765", 11) 206 | 5 207 | >>> _hashdigit("52599927765", 10) 208 | 6 209 | """ 210 | 211 | val = ( 212 | sum( 213 | int(digit) * weight 214 | for digit, weight in zip(cpf, range(position, 1, -1)) 215 | ) 216 | % 11 217 | ) 218 | 219 | return 0 if val < 2 else 11 - val 220 | 221 | 222 | def _checksum(basenum: str) -> str: 223 | """ 224 | Compute the checksum digits for a given CPF base number. 225 | 226 | This function calculates the checksum digits for a given CPF base number. 227 | The base number should be a digit string of adequate length. 228 | 229 | Args: 230 | basenum (str): A digit string of adequate length. 231 | 232 | Returns: 233 | str: The calculated checksum digits. 234 | 235 | Example: 236 | >>> _checksum("335451269") 237 | '51' 238 | >>> _checksum("382916331") 239 | '26' 240 | """ 241 | 242 | verifying_digits = str(_hashdigit(basenum, 10)) 243 | verifying_digits += str(_hashdigit(basenum + verifying_digits, 11)) 244 | 245 | return verifying_digits 246 | -------------------------------------------------------------------------------- /brutils/license_plate.py: -------------------------------------------------------------------------------- 1 | import re 2 | from random import choice, randint 3 | from string import ascii_uppercase 4 | from typing import Literal 5 | 6 | # FORMATTING 7 | ############ 8 | 9 | 10 | def convert_to_mercosul(license_plate: str) -> str | None: 11 | """ 12 | Converts an old pattern license plate (LLLNNNN) to a Mercosul format 13 | (LLLNLNN). 14 | 15 | Args: 16 | license_plate (str): A string of proper length representing the 17 | old pattern license plate. 18 | 19 | Returns: 20 | str | None: The converted Mercosul license plate (LLLNLNN) or 21 | 'None' if the input is invalid. 22 | 23 | Example: 24 | >>> convert_to_mercosul("ABC4567") 25 | 'ABC4F67' 26 | >>> convert_to_mercosul("ABC4*67") 27 | None 28 | """ 29 | if not _is_valid_old_format(license_plate): 30 | return None 31 | 32 | digits = [letter for letter in license_plate.upper()] 33 | digits[4] = chr(ord("A") + int(digits[4])) 34 | return "".join(digits) 35 | 36 | 37 | def format_license_plate(license_plate: str) -> str | None: 38 | """ 39 | Formats a license plate into the correct pattern. 40 | This function receives a license plate in any pattern (LLLNNNN or LLLNLNN) 41 | and returns a formatted version. 42 | 43 | Args: 44 | license_plate (str): A license plate string. 45 | 46 | Returns: 47 | str | None: The formatted license plate string or 'None' if the 48 | input is invalid. 49 | 50 | Example: 51 | >>> format("ABC1234") # old format (contains a dash) 52 | 'ABC-1234' 53 | >>> format("abc1e34") # mercosul format 54 | 'ABC1E34' 55 | >>> format("ABC123") 56 | None 57 | """ 58 | 59 | license_plate = license_plate.upper() 60 | if _is_valid_old_format(license_plate): 61 | return license_plate[0:3] + "-" + license_plate[3:] 62 | elif _is_valid_mercosul(license_plate): 63 | return license_plate.upper() 64 | 65 | return None 66 | 67 | 68 | # OPERATIONS 69 | ############ 70 | 71 | 72 | def is_valid( 73 | license_plate: str, type: Literal["old_format", "mercosul"] | None = None 74 | ) -> bool: 75 | """ 76 | Returns if a Brazilian license plate number is valid. 77 | It does not verify if the plate actually exists. 78 | 79 | Args: 80 | license_plate (str): The license plate number to be validated. 81 | type (Literal["old_format", "mercosul"] | None): "old_format" or "mercosul". 82 | If not specified, checks for one or another. 83 | Returns: 84 | bool: True if the plate number is valid. False otherwise. 85 | """ 86 | 87 | if type == "old_format": 88 | return _is_valid_old_format(license_plate) 89 | if type == "mercosul": 90 | return _is_valid_mercosul(license_plate) 91 | 92 | return _is_valid_old_format(license_plate) or _is_valid_mercosul( 93 | license_plate 94 | ) 95 | 96 | 97 | def remove_symbols(license_plate_number: str) -> str: 98 | """ 99 | Removes the dash (-) symbol from a license plate string. 100 | 101 | Args: 102 | license_plate_number (str): A license plate number containing symbols to 103 | be removed. 104 | 105 | Returns: 106 | str | None: The license plate number with the specified symbols removed. 107 | 108 | Example: 109 | >>> remove_symbols("ABC-123") 110 | "ABC123" 111 | >>> remove_symbols("abc123") 112 | "abc123" 113 | >>> remove_symbols("ABCD123") 114 | "ABCD123" 115 | """ 116 | 117 | return license_plate_number.replace("-", "") 118 | 119 | 120 | def get_format(license_plate: str) -> str | None: 121 | """ 122 | Return the format of a license plate. 'LLLNNNN' for the old pattern and 123 | 'LLLNLNN' for the Mercosul one. 124 | 125 | Args: 126 | license_plate (str): A license plate string without symbols. 127 | 128 | Returns: 129 | str | None: The format of the license plate (LLLNNNN, LLLNLNN) or 130 | 'None' if the format is invalid. 131 | 132 | Example: 133 | >>> get_format("abc123") 134 | "LLLNNNN" 135 | >>> get_format("abc1d23") 136 | "LLLNLNN" 137 | >>> get_format("ABCD123") 138 | None 139 | """ 140 | 141 | if _is_valid_old_format(license_plate): 142 | return "LLLNNNN" 143 | 144 | if _is_valid_mercosul(license_plate): 145 | return "LLLNLNN" 146 | 147 | return None 148 | 149 | 150 | def generate(format: str = "LLLNLNN") -> str | None: 151 | """ 152 | Generate a valid license plate in the given format. In case no format is 153 | provided, it will return a license plate in the Mercosul format. 154 | 155 | Args: 156 | format (str): The desired format for the license plate. 157 | 'LLLNNNN' for the old pattern or 'LLLNLNN' for the 158 | Mercosul one. Default is 'LLLNLNN' 159 | 160 | Returns: 161 | str | None: A randomly generated license plate number or 162 | 'None' if the format is invalid. 163 | 164 | Example: 165 | >>> generate() 166 | "ABC1D23" 167 | >>> generate(format="LLLNLNN") 168 | "ABC4D56" 169 | >>> generate(format="LLLNNNN") 170 | "ABC123" 171 | >>> generate(format="invalid") 172 | None 173 | """ 174 | 175 | generated = "" 176 | 177 | format = format.upper() 178 | 179 | if format not in ("LLLNLNN", "LLLNNNN"): 180 | return None 181 | 182 | for char in format: 183 | if char == "L": 184 | generated += choice(ascii_uppercase) 185 | else: 186 | generated += str(randint(0, 9)) 187 | 188 | return generated 189 | 190 | 191 | def _is_valid_old_format(license_plate: str) -> bool: 192 | """ 193 | Checks whether a string matches the old format of Brazilian license plate. 194 | Args: 195 | license_plate (str): The desired format for the license plate. 196 | pattern:'LLLNNNN' 197 | 198 | Returns: 199 | bool: True if the plate number is valid. False otherwise. 200 | 201 | """ 202 | pattern = re.compile(r"^[A-Za-z]{3}[0-9]{4}$") 203 | return ( 204 | isinstance(license_plate, str) 205 | and re.match(pattern, license_plate.strip()) is not None 206 | ) 207 | 208 | 209 | def _is_valid_mercosul(license_plate: str) -> bool: 210 | """ 211 | Checks whether a string matches the old format of Brazilian license plate. 212 | Args: 213 | license_plate (str): The desired format for the license plate. 214 | pattern:'LLLNLNN' 215 | 216 | Returns: 217 | bool: True if the plate number is valid. False otherwise. 218 | 219 | """ 220 | if not isinstance(license_plate, str): 221 | return False 222 | 223 | license_plate = license_plate.upper().strip() 224 | pattern = re.compile(r"^[A-Z]{3}\d[A-Z]\d{2}$") 225 | return re.match(pattern, license_plate) is not None 226 | -------------------------------------------------------------------------------- /tests/test_date_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest import TestCase 3 | from unittest.mock import patch 4 | 5 | from num2words import num2words 6 | 7 | from brutils import convert_date_to_text 8 | from brutils.data.enums.months import MonthsEnum 9 | from brutils.date_utils import is_holiday 10 | 11 | 12 | class TestIsHoliday(TestCase): 13 | @patch("brutils.date_utils.is_holiday") 14 | def test_feriados_validos(self, mock_is_holiday): 15 | # Testes com feriados válidos 16 | mock_is_holiday.return_value = True 17 | self.assertTrue(is_holiday(datetime(2024, 1, 1))) # Ano Novo 18 | self.assertTrue( 19 | is_holiday(datetime(2024, 7, 9), uf="SP") 20 | ) # Revolução Constitucionalista (SP) 21 | self.assertTrue( 22 | is_holiday(datetime(2024, 9, 7)) 23 | ) # Independência do Brasil 24 | self.assertTrue(is_holiday(datetime(2025, 1, 1))) # Ano Novo 25 | 26 | @patch("brutils.date_utils.is_holiday") 27 | def test_dias_normais(self, mock_is_holiday): 28 | # Testes com dias normais 29 | mock_is_holiday.return_value = False 30 | self.assertFalse(is_holiday(datetime(2024, 1, 2))) # Dia normal 31 | self.assertFalse( 32 | is_holiday(datetime(2024, 7, 9), uf="RJ") 33 | ) # Dia normal no RJ 34 | 35 | @patch("brutils.date_utils.is_holiday") 36 | def test_data_invalida(self, mock_is_holiday): 37 | # Testes com data inválida 38 | mock_is_holiday.return_value = None 39 | self.assertIsNone(is_holiday("2024-01-01")) # Formato incorreto 40 | self.assertIsNone(is_holiday(None)) # Data None 41 | 42 | @patch("brutils.date_utils.is_holiday") 43 | def test_uf_invalida(self, mock_is_holiday): 44 | # Testes com UF inválida 45 | mock_is_holiday.return_value = None 46 | self.assertIsNone( 47 | is_holiday(datetime(2024, 1, 1), uf="XX") 48 | ) # UF inválida 49 | self.assertIsNone( 50 | is_holiday(datetime(2024, 1, 1), uf="SS") 51 | ) # UF inválida 52 | 53 | @patch("brutils.date_utils.is_holiday") 54 | def test_limite_de_datas(self, mock_is_holiday): 55 | # Testes com limite de datas 56 | mock_is_holiday.return_value = True 57 | self.assertTrue(is_holiday(datetime(2024, 12, 25))) # Natal 58 | self.assertTrue( 59 | is_holiday(datetime(2024, 11, 15)) 60 | ) # Proclamação da República 61 | 62 | @patch("brutils.date_utils.is_holiday") 63 | def test_datas_depois_de_feriados(self, mock_is_holiday): 64 | # Test data after holidays 65 | mock_is_holiday.return_value = False 66 | self.assertFalse(is_holiday(datetime(2024, 12, 26))) # Não é feriado 67 | self.assertFalse(is_holiday(datetime(2025, 1, 2))) # Não é feriado 68 | 69 | @patch("brutils.date_utils.is_holiday") 70 | def test_ano_bissexto(self, mock_is_holiday): 71 | # Teste ano bissexto 72 | mock_is_holiday.return_value = False 73 | self.assertFalse( 74 | is_holiday(datetime(2024, 2, 29)) 75 | ) # Não é feriado, mas data válida 76 | # Uncomment to test non-leap year invalid date 77 | # self.assertIsNone(is_holiday(datetime(1900, 2, 29))) # Ano não bissexto, data inválida 78 | 79 | @patch("brutils.date_utils.is_holiday") 80 | def test_data_passada_futura(self, mock_is_holiday): 81 | # Teste de data passada e futura 82 | mock_is_holiday.return_value = True 83 | self.assertTrue(is_holiday(datetime(2023, 1, 1))) # Ano anterior 84 | self.assertTrue(is_holiday(datetime(2100, 12, 25))) # Ano futuro 85 | self.assertFalse( 86 | is_holiday(datetime(2100, 1, 2)) 87 | ) # Dia normal em ano futuro 88 | 89 | @patch("brutils.date_utils.is_holiday") 90 | def test_data_sem_uf(self, mock_is_holiday): 91 | # Teste feriado nacional sem UF 92 | mock_is_holiday.return_value = True 93 | self.assertTrue( 94 | is_holiday(datetime(2024, 12, 25)) 95 | ) # Natal, feriado nacional 96 | self.assertFalse( 97 | is_holiday(datetime(2024, 7, 9)) 98 | ) # Data estadual de SP, sem UF 99 | 100 | 101 | class TestNum2Words(TestCase): 102 | def test_num_conversion(self) -> None: 103 | """ 104 | Smoke test of the num2words library. 105 | This test is used to guarantee that our dependency still works. 106 | """ 107 | self.assertEqual(num2words(30, lang="pt-br"), "trinta") 108 | self.assertEqual(num2words(42, lang="pt-br"), "quarenta e dois") 109 | self.assertEqual( 110 | num2words(2024, lang="pt-br"), "dois mil e vinte e quatro" 111 | ) 112 | self.assertEqual(num2words(0, lang="pt-br"), "zero") 113 | self.assertEqual(num2words(-1, lang="pt-br"), "menos um") 114 | 115 | 116 | class TestConvertDateToText(TestCase): 117 | def test_convert_date_to_text(self): 118 | self.assertEqual( 119 | convert_date_to_text("15/08/2024"), 120 | "Quinze de agosto de dois mil e vinte e quatro", 121 | ) 122 | self.assertEqual( 123 | convert_date_to_text("01/01/2000"), 124 | "Primeiro de janeiro de dois mil", 125 | ) 126 | self.assertEqual( 127 | convert_date_to_text("31/12/1999"), 128 | "Trinta e um de dezembro de mil novecentos e noventa e nove", 129 | ) 130 | 131 | self.assertIsNone(convert_date_to_text("30/02/2020"), None) 132 | self.assertIsNone(convert_date_to_text("30/00/2020"), None) 133 | self.assertIsNone(convert_date_to_text("30/02/2000"), None) 134 | self.assertIsNone(convert_date_to_text("50/09/2000"), None) 135 | self.assertIsNone(convert_date_to_text("25/15/2000"), None) 136 | self.assertIsNone(convert_date_to_text("29/02/2019"), None) 137 | 138 | # Invalid date pattern. 139 | self.assertIsNone(convert_date_to_text("Invalid")) 140 | self.assertIsNone(convert_date_to_text("25/1/2020")) 141 | self.assertIsNone(convert_date_to_text("1924/08/20")) 142 | self.assertIsNone(convert_date_to_text("5/09/2020")) 143 | self.assertIsNone(convert_date_to_text("00/09/2020")) 144 | self.assertIsNone(convert_date_to_text("32/09/2020")) 145 | 146 | self.assertEqual( 147 | convert_date_to_text("29/02/2020"), 148 | "Vinte e nove de fevereiro de dois mil e vinte", 149 | ) 150 | self.assertEqual( 151 | convert_date_to_text("01/01/1900"), 152 | "Primeiro de janeiro de mil e novecentos", 153 | ) 154 | 155 | months_year = [ 156 | (1, "janeiro"), 157 | (2, "fevereiro"), 158 | (3, "marco"), 159 | (4, "abril"), 160 | (5, "maio"), 161 | (6, "junho"), 162 | (7, "julho"), 163 | (8, "agosto"), 164 | (9, "setembro"), 165 | (10, "outubro"), 166 | (11, "novembro"), 167 | (12, "dezembro"), 168 | ] 169 | 170 | def testMonthEnum(self): 171 | for number_month, name_month in self.months_year: 172 | month = MonthsEnum(number_month) 173 | self.assertEqual(month.month_name, name_month) 174 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | - Utilitário `convert_name_to_uf` 13 | - Utilitário `is_valid_legal_nature` [#641](https://github.com/brazilian-utils/python/issues/641) 14 | - Utilitário `get_legal_nature_description` [#641](https://github.com/brazilian-utils/python/issues/641) 15 | - Utilitário `list_all_legal_nature` [#641](https://github.com/brazilian-utils/python/issues/641) 16 | - Utilitário `is_valid_cnh` [#651](https://github.com/brazilian-utils/brutils-python/pull/651) 17 | - Utilitário `is_valid_renavam` [#652](https://github.com/brazilian-utils/brutils-python/pull/652) 18 | 19 | ### Fixed 20 | 21 | - Suporte a anotações modernas no `brutils/cep.py` [#637](https://github.com/brazilian-utils/python/pull/637) 22 | 23 | ### Fixed 24 | 25 | - Suporte a anotações modernas `legal_process` [#622](https://github.com/brazilian-utils/brutils-python/pull/634) 26 | 27 | ### Fixed 28 | 29 | - Suporte a anotações modernas `voter_id` [#623](https://github.com/brazilian-utils/brutils-python/pull/638) 30 | 31 | ## [2.3.0] - 2025-10-07 32 | 33 | ### Added 34 | 35 | - Utilitário `convert_code_to_uf` [#410](https://github.com/brazilian-utils/brutils-python/pull/410) 36 | - Utilitário `is_holiday` [#446](https://github.com/brazilian-utils/brutils-python/pull/446) 37 | - Utilitário `convert_date_to_text`[#415](https://github.com/brazilian-utils/brutils-python/pull/415) 38 | - Utilitário `get_municipality_by_code` [412](https://github.com/brazilian-utils/brutils-python/pull/412) 39 | - Utilitário `get_code_by_municipality_name` [#411](https://github.com/brazilian-utils/brutils-python/pull/411) 40 | - Utilitário `format_currency` [#434](https://github.com/brazilian-utils/brutils-python/pull/434) 41 | - Utilitário `convert_real_to_text` [#525](https://github.com/brazilian-utils/brutils-python/pull/525) 42 | - Utilitário `convert_uf_to_name` [#554](https://github.com/brazilian-utils/brutils-python/pull/554) 43 | 44 | ### Deprecated 45 | 46 | - **BREAKING CHANGES** Suporte ao Python 3.8 [#236](https://github.com/brazilian-utils/brutils-python/pull/561) 47 | - **BREAKING CHANGES** Suporte ao Python 3.9 [#236](https://github.com/brazilian-utils/brutils-python/pull/561) 48 | 49 | ## [2.2.0] - 2024-09-12 50 | 51 | ### Added 52 | 53 | - Utilitário `get_address_from_cep` [#358](https://github.com/brazilian-utils/brutils-python/pull/358) 54 | - Utilitário `get_cep_information_from_address` [#358](https://github.com/brazilian-utils/brutils-python/pull/358) 55 | - Utilitário `format_voter_id` [#363](https://github.com/brazilian-utils/brutils-python/pull/363) 56 | - Utilitário `generate_voter_id` [#220](https://github.com/brazilian-utils/brutils-python/pull/220) 57 | 58 | ## [2.1.1] - 2024-01-06 59 | 60 | ### Fixed 61 | 62 | - `generate_legal_process` [#325](https://github.com/brazilian-utils/brutils-python/pull/325) 63 | - `is_valid_legal_process` [#325](https://github.com/brazilian-utils/brutils-python/pull/325) 64 | - Import do utilitário `convert_license_plate_to_mercosul` [#324](https://github.com/brazilian-utils/brutils-python/pull/324) 65 | - Import do utilitário `generate_license_plate` [#324](https://github.com/brazilian-utils/brutils-python/pull/324) 66 | - Import do utilitário `get_format_license_plate` [#324](https://github.com/brazilian-utils/brutils-python/pull/324) 67 | 68 | ## [2.1.0] - 2024-01-05 69 | 70 | ### Added 71 | 72 | - Suporte ao Python 3.12 [#245](https://github.com/brazilian-utils/brutils-python/pull/245) 73 | - Utilitário `convert_license_plate_to_mercosul` [#226](https://github.com/brazilian-utils/brutils-python/pull/226) 74 | - Utilitário `format_license_plate` [#230](https://github.com/brazilian-utils/brutils-python/pull/230) 75 | - Utilitário `format_phone` [#231](https://github.com/brazilian-utils/brutils-python/pull/231) 76 | - Utilitário `format_pis` [#224](https://github.com/brazilian-utils/brutils-python/pull/224) 77 | - Utilitário `format_legal_process` [#210](https://github.com/brazilian-utils/brutils-python/pull/210) 78 | - Utilitário `generate_license_plate` [#241](https://github.com/brazilian-utils/brutils-python/pull/241) 79 | - Utilitário `generate_phone` [#295](https://github.com/brazilian-utils/brutils-python/pull/295) 80 | - Utilitário `generate_pis` [#218](https://github.com/brazilian-utils/brutils-python/pull/218) 81 | - Utilitário `generate_legal_process` [#208](https://github.com/brazilian-utils/brutils-python/pull/208) 82 | - Utilitário `get_format_license_plate` [#243](https://github.com/brazilian-utils/brutils-python/pull/243) 83 | - Utilitário `is_valid_email` [#213](https://github.com/brazilian-utils/brutils-python/pull/213) 84 | - Utilitário `is_valid_license_plate` [#237](https://github.com/brazilian-utils/brutils-python/pull/237) 85 | - Utilitário `is_valid_phone` [#147](https://github.com/brazilian-utils/brutils-python/pull/147) 86 | - Utilitário `is_valid_pis` [#216](https://github.com/brazilian-utils/brutils-python/pull/216) 87 | - Utilitário `is_valid_legal_process` [#207](https://github.com/brazilian-utils/brutils-python/pull/207) 88 | - Utilitário `is_valid_voter_id` [#235](https://github.com/brazilian-utils/brutils-python/pull/235) 89 | - Utilitário `remove_international_dialing_code` [192](https://github.com/brazilian-utils/brutils-python/pull/192) 90 | - Utilitário `remove_symbols_license_plate` [#182](https://github.com/brazilian-utils/brutils-python/pull/182) 91 | - Utilitário `remove_symbols_phone` [#188](https://github.com/brazilian-utils/brutils-python/pull/188) 92 | - Utilitário `remove_symbols_pis` [#236](https://github.com/brazilian-utils/brutils-python/pull/236) 93 | - Utilitário `remove_symbols_legal_process` [#209](https://github.com/brazilian-utils/brutils-python/pull/209) 94 | 95 | ### Removed 96 | 97 | - **BREAKING CHANGE** Suporte ao Python 3.7 [#236](https://github.com/brazilian-utils/brutils-python/pull/236) 98 | 99 | ## [2.0.0] - 2023-07-23 100 | 101 | ### Added 102 | 103 | - Utilitário `is_valid_cep` [123](https://github.com/brazilian-utils/brutils-python/pull/123) 104 | - Utilitário `format_cep` [125](https://github.com/brazilian-utils/brutils-python/pull/125) 105 | - Utilitário `remove_symbols_cep` [126](https://github.com/brazilian-utils/brutils-python/pull/126) 106 | - Utilitário `generate_cep` [124](https://github.com/brazilian-utils/brutils-python/pull/124) 107 | - Utilitário `is_valid_cpf` [34](https://github.com/brazilian-utils/brutils-python/pull/34) 108 | - Utilitário `format_cpf` [54](https://github.com/brazilian-utils/brutils-python/pull/54) 109 | - Utilitário `remove_symbols_cpf` [57](https://github.com/brazilian-utils/brutils-python/pull/57) 110 | - Utilitário `is_valid_cnpj` [36](https://github.com/brazilian-utils/brutils-python/pull/36) 111 | - Utilitário `format_cnpj` [52](https://github.com/brazilian-utils/brutils-python/pull/52) 112 | - Utilitário `remove_symbols_cnpj` [58](https://github.com/brazilian-utils/brutils-python/pull/58) 113 | 114 | ### Deprecated 115 | 116 | - Utilitário `cpf.sieve` 117 | - Utilitário `cpf.display` 118 | - Utilitário `cpf.validate` 119 | - Utilitário `cnpj.sieve` 120 | - Utilitário `cnpj.display` 121 | - Utilitário `cnpj.validate` 122 | 123 | [Unreleased]: https://github.com/brazilian-utils/brutils-python/compare/v2.3.0...HEAD 124 | [2.3.0]: https://github.com/brazilian-utils/brutils-python/releases/tag/v2.3.0 125 | [2.2.0]: https://github.com/brazilian-utils/brutils-python/releases/tag/v2.2.0 126 | [2.1.1]: https://github.com/brazilian-utils/brutils-python/releases/tag/v2.1.1 127 | [2.1.0]: https://github.com/brazilian-utils/brutils-python/releases/tag/v2.1.0 128 | [2.0.0]: https://github.com/brazilian-utils/brutils-python/releases/tag/v2.0.0 129 | -------------------------------------------------------------------------------- /brutils/cnpj.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from random import randint 3 | 4 | # FORMATTING 5 | ############ 6 | 7 | 8 | def sieve(dirty: str) -> str: 9 | """ 10 | Removes specific symbols from a CNPJ (Brazilian Company Registration 11 | Number) string. 12 | 13 | This function takes a CNPJ string as input and removes all occurrences of 14 | the '.', '/' and '-' characters from it. 15 | 16 | Args: 17 | cnpj (str): The CNPJ string containing symbols to be removed. 18 | 19 | Returns: 20 | str: A new string with the specified symbols removed. 21 | 22 | Example: 23 | >>> sieve("12.345/6789-01") 24 | "12345678901" 25 | >>> sieve("98/76.543-2101") 26 | "98765432101" 27 | 28 | .. note:: 29 | This method should not be used in new code and is only provided for 30 | backward compatibility. 31 | """ 32 | 33 | return "".join(filter(lambda char: char not in "./-", dirty)) 34 | 35 | 36 | def remove_symbols(dirty: str) -> str: 37 | """ 38 | This function is an alias for the `sieve` function, offering a more 39 | descriptive name. 40 | 41 | Args: 42 | dirty (str): The dirty string containing symbols to be removed. 43 | 44 | Returns: 45 | str: A new string with the specified symbols removed. 46 | 47 | Example: 48 | >>> remove_symbols("12.345/6789-01") 49 | "12345678901" 50 | >>> remove_symbols("98/76.543-2101") 51 | "98765432101" 52 | """ 53 | 54 | return sieve(dirty) 55 | 56 | 57 | def display(cnpj: str) -> str | None: 58 | """ 59 | Will format an adequately formatted numbers-only CNPJ string, 60 | adding in standard formatting visual aid symbols for display. 61 | 62 | Formats a CNPJ (Brazilian Company Registration Number) string for 63 | visual display. 64 | 65 | This function takes a CNPJ string as input, validates its format, and 66 | formats it with standard visual aid symbols for display purposes. 67 | 68 | Args: 69 | cnpj (str): The CNPJ string to be formatted for display. 70 | 71 | Returns: 72 | str: The formatted CNPJ with visual aid symbols if it's valid, 73 | None if it's not valid. 74 | 75 | Example: 76 | >>> display("12345678901234") 77 | "12.345.678/9012-34" 78 | >>> display("98765432100100") 79 | "98.765.432/1001-00" 80 | 81 | .. note:: 82 | This method should not be used in new code and is only provided for 83 | backward compatibility. 84 | """ 85 | 86 | if not cnpj.isdigit() or len(cnpj) != 14 or len(set(cnpj)) == 1: 87 | return None 88 | return "{}.{}.{}/{}-{}".format( 89 | cnpj[:2], cnpj[2:5], cnpj[5:8], cnpj[8:12], cnpj[12:] 90 | ) 91 | 92 | 93 | def format_cnpj(cnpj: str) -> str | None: 94 | """ 95 | Formats a CNPJ (Brazilian Company Registration Number) string for visual 96 | display. 97 | 98 | This function takes a CNPJ string as input, validates its format, and 99 | formats it with standard visual aid symbols for display purposes. 100 | 101 | Args: 102 | cnpj (str): The CNPJ string to be formatted for display. 103 | 104 | Returns: 105 | str: The formatted CNPJ with visual aid symbols if it's valid, 106 | None if it's not valid. 107 | 108 | Example: 109 | >>> format_cnpj("03560714000142") 110 | '03.560.714/0001-42' 111 | >>> format_cnpj("98765432100100") 112 | None 113 | """ 114 | 115 | if not is_valid(cnpj): 116 | return None 117 | 118 | return "{}.{}.{}/{}-{}".format( 119 | cnpj[:2], cnpj[2:5], cnpj[5:8], cnpj[8:12], cnpj[12:14] 120 | ) 121 | 122 | 123 | # OPERATIONS 124 | ############ 125 | 126 | 127 | def validate(cnpj: str) -> bool: 128 | """ 129 | Validates a CNPJ (Brazilian Company Registration Number) by comparing its 130 | verifying checksum digits to its base number. 131 | 132 | This function checks the validity of a CNPJ by comparing its verifying 133 | checksum digits to its base number. The input should be a string of digits 134 | with the appropriate length. 135 | 136 | Args: 137 | cnpj (str): The CNPJ to be validated. 138 | 139 | Returns: 140 | bool: True if the checksum digits match the base number, 141 | False otherwise. 142 | 143 | Example: 144 | >>> validate("03560714000142") 145 | True 146 | >>> validate("00111222000133") 147 | False 148 | 149 | .. note:: 150 | This method should not be used in new code and is only provided for 151 | backward compatibility. 152 | """ 153 | 154 | if not cnpj.isdigit() or len(cnpj) != 14 or len(set(cnpj)) == 1: 155 | return False 156 | return all( 157 | _hashdigit(cnpj, i + 13) == int(v) for i, v in enumerate(cnpj[12:]) 158 | ) 159 | 160 | 161 | def is_valid(cnpj: str) -> bool: 162 | """ 163 | Returns whether or not the verifying checksum digits of the given `cnpj` 164 | match its base number. 165 | 166 | This function does not verify the existence of the CNPJ; it only 167 | validates the format of the string. 168 | 169 | Args: 170 | cnpj (str): The CNPJ to be validated, a 14-digit string 171 | 172 | Returns: 173 | bool: True if the checksum digits match the base number, 174 | False otherwise. 175 | 176 | Example: 177 | >>> is_valid("03560714000142") 178 | True 179 | >>> is_valid("00111222000133") 180 | False 181 | """ 182 | 183 | return isinstance(cnpj, str) and validate(cnpj) 184 | 185 | 186 | def generate(branch: int = 1) -> str: 187 | """ 188 | Generates a random valid CNPJ digit string. An optional branch number 189 | parameter can be given; it defaults to 1. 190 | 191 | Args: 192 | branch (int): An optional branch number to be included in the CNPJ. 193 | 194 | Returns: 195 | str: A randomly generated valid CNPJ string. 196 | 197 | Example: 198 | >>> generate() 199 | "30180536000105" 200 | >>> generate(1234) 201 | "01745284123455" 202 | """ 203 | 204 | branch %= 10000 205 | branch += int(branch == 0) 206 | branch = str(branch).zfill(4) 207 | base = str(randint(0, 99999999)).zfill(8) + branch 208 | 209 | return base + _checksum(base) 210 | 211 | 212 | def _hashdigit(cnpj: str, position: int) -> int: 213 | """ 214 | Calculates the checksum digit at the given `position` for the provided 215 | `cnpj`. The input must contain all elements before `position`. 216 | 217 | Args: 218 | cnpj (str): The CNPJ for which the checksum digit is calculated. 219 | position (int): The position of the checksum digit to be calculated. 220 | 221 | Returns: 222 | int: The calculated checksum digit. 223 | 224 | Example: 225 | >>> _hashdigit("12345678901234", 13) 226 | 3 227 | >>> _hashdigit("98765432100100", 14) 228 | 9 229 | """ 230 | 231 | weightgen = chain(range(position - 8, 1, -1), range(9, 1, -1)) 232 | val = ( 233 | sum(int(digit) * weight for digit, weight in zip(cnpj, weightgen)) % 11 234 | ) 235 | return 0 if val < 2 else 11 - val 236 | 237 | 238 | def _checksum(basenum: str) -> str: 239 | """ 240 | Calculates the verifying checksum digits for a given CNPJ base number. 241 | 242 | This function computes the verifying checksum digits for a provided CNPJ 243 | base number. The `basenum` should be a digit-string of the appropriate 244 | length. 245 | 246 | Args: 247 | basenum (str): The base number of the CNPJ for which verifying checksum 248 | digits are calculated. 249 | 250 | Returns: 251 | str: The verifying checksum digits. 252 | 253 | Example: 254 | >>> _checksum("123456789012") 255 | "30" 256 | >>> _checksum("987654321001") 257 | "41" 258 | """ 259 | 260 | verifying_digits = str(_hashdigit(basenum, 13)) 261 | verifying_digits += str(_hashdigit(basenum + verifying_digits, 14)) 262 | return verifying_digits 263 | -------------------------------------------------------------------------------- /MAINTAINING.md: -------------------------------------------------------------------------------- 1 | # Prompt ChatGPT para Criar Issues 2 | 3 | ## Exemplo: formatar moeda brasileira 4 | 5 | - Criar uma issue para o repositório brutils-python. 6 | - Issue: formatar moeda brasileira 7 | - nome da função: format_brl 8 | - entrada: float 9 | - saída: string formatada 10 | - caso entrada seja inválida, retornar None 11 | - Não implementar a lógica da função. Apenas deixar a docstring e um comentário `# implementar a lógica da função aqui` 12 | - Considerar o maior número de edge cases possíveis 13 | - Criar testes unitários para todos os edge cases 14 | - Estes são os utilitários já existentes na lib: 15 | 16 | ```md 17 | - [CPF](#cpf) 18 | - [is\_valid\_cpf](#is_valid_cpf) 19 | - [format\_cpf](#format_cpf) 20 | - [remove\_symbols\_cpf](#remove_symbols_cpf) 21 | - [generate\_cpf](#generate_cpf) 22 | - [CNPJ](#cnpj) 23 | - [is\_valid\_cnpj](#is_valid_cnpj) 24 | - [format\_cnpj](#format_cnpj) 25 | - [remove\_symbols\_cnpj](#remove_symbols_cnpj) 26 | - [generate\_cnpj](#generate_cnpj) 27 | - [CEP](#cep) 28 | - [is\_valid\_cep](#is_valid_cep) 29 | - [format\_cep](#format_cep) 30 | - [remove\_symbols\_cep](#remove_symbols_cep) 31 | - [generate\_cep](#generate_cep) 32 | - [get\_address\_from\_cep](#get_address_from_cep) 33 | - [get\_cep\_information\_from\_address](#get_cep_information_from_address) 34 | - [Telefone](#telefone) 35 | - [is\_valid\_phone](#is_valid_phone) 36 | - [format\_phone](#format_phone) 37 | - [remove\_symbols\_phone](#remove_symbols_phone) 38 | - [remove\_international\_dialing\_code](#remove_international_dialing_code) 39 | - [generate\_phone](#generate_phone) 40 | - [Email](#email) 41 | - [is\_valid\_email](#is_valid_email) 42 | - [Placa de Carro](#placa-de-carro) 43 | - [is\_valid\_license\_plate](#is_valid_license_plate) 44 | - [format\_license\_plate](#format_license_plate) 45 | - [remove\_symbols\_license\_plate](#remove_symbols_license_plate) 46 | - [generate\_license\_plate](#generate_license_plate) 47 | - [convert\_license\_plate\_to\_mercosul](#convert_license_plate_to_mercosul) 48 | - [get\_format\_license\_plate](#get_format_license_plate) 49 | - [PIS](#pis) 50 | - [is\_valid\_pis](#is_valid_pis) 51 | - [format\_pis](#format_pis) 52 | - [remove\_symbols\_pis](#remove_symbols_pis) 53 | - [generate\_pis](#generate_pis) 54 | - [Processo Jurídico](#processo-jurídico) 55 | - [is\_valid\_legal\_process](#is_valid_legal_process) 56 | - [format\_legal\_process](#format_legal_process) 57 | - [remove\_symbols\_legal\_process](#remove_symbols_legal_process) 58 | - [generate\_legal\_process](#generate_legal_process) 59 | - [Título Eleitoral](#titulo-eleitoral) 60 | - [is_valid_voter_id](#is_valid_voter_id) 61 | - [format_voter_id](#format_voter_id) 62 | - [generate_voter_id](#generate_voter_id) 63 | - [IBGE](#ibge) 64 | - [convert_code_to_uf](#convert_code_to_uf) 65 | ``` 66 | 67 | - Seguindo exatamento o mesmo modelo dessa issue: 68 | 69 | ```md 70 | Título da Issue: Conversão de Nome de Estado para UF 71 | 72 | **Seu pedido de recurso está relacionado a um problema? Por favor, descreva.** 73 | 74 | Dado o nome completo de um estado brasileiro, quero obter o código de Unidade Federativa (UF) correspondente. Isso é útil para conversão de nomes completos de estados em siglas utilizadas em sistemas e documentos. 75 | 76 | Por exemplo, converter `"São Paulo"` para `"SP"`. 77 | 78 | **Descreva a solução que você gostaria** 79 | 80 | * Uma função `convert_text_to_uf`, que recebe o nome completo do estado (string) e retorna o código UF correspondente. 81 | * A função deve ignorar maiúsculas e minúsculas, e também deve desconsiderar acentos e o caractere especial ç (considerando c também). 82 | * A função deve verificar se o nome completo é válido e retornar o código UF correspondente. 83 | * Se o nome completo não for válido, a função deve retornar `None`. 84 | * A função deve lidar com todos os estados e o Distrito Federal do Brasil. 85 | * A lista das UFs e seus nomes completos já existe no arquivo `brutils/data/enums/uf.py`. Ela deve ser reutilizada. 86 | 87 | **Descreva alternativas que você considerou** 88 | 89 | 1. Seguir até o passo 8 do [guia de contribuição](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#primeira-contribui%C3%A7%C3%A3o). 90 | 91 | 2. Como parte do passo 8, criar o arquivo: `brutils-python/brutils/ibge/uf.py`. 92 | 93 | ```python 94 | def convert_text_to_uf(state_name): # type: (str) -> str | None 95 | """ 96 | Converts a given Brazilian state full name to its corresponding UF code. 97 | 98 | This function takes the full name of a Brazilian state and returns the corresponding 99 | 2-letter UF code. It handles all Brazilian states and the Federal District. 100 | 101 | Args: 102 | state_name (str): The full name of the state to be converted. 103 | 104 | Returns: 105 | str or None: The UF code corresponding to the full state name, 106 | or None if the full state name is invalid. 107 | 108 | Example: 109 | >>> convert_text_to_uf('São Paulo') 110 | "SP" 111 | >>> convert_text_to_uf('Rio de Janeiro') 112 | "RJ" 113 | >>> convert_text_to_uf('Minas Gerais') 114 | "MG" 115 | >>> convert_text_to_uf('Distrito Federal') 116 | "DF" 117 | >>> convert_text_to_uf('Estado Inexistente') 118 | None 119 | """ 120 | # implementar a lógica da função aqui 121 | ``` 122 | 123 | Importar a nova função no arquivo `brutils-python/brutils/__init__.py`: 124 | 125 | ```python 126 | # UF Imports 127 | from brutils.ibge.uf import ( 128 | convert_text_to_uf, 129 | ) 130 | ``` 131 | 132 | E adicionar o nome da nova função na lista `__all__` do mesmo arquivo `brutils-python/brutils/__init__.py`: 133 | 134 | ```python 135 | __all__ = [ 136 | ... 137 | # UF 138 | 'convert_text_to_uf', 139 | ] 140 | ``` 141 | 142 | 3. Como parte do passo 9, criar o arquivo de teste: `brutils-python/tests/test_uf.py`. 143 | 144 | ```python 145 | from unittest import TestCase 146 | from brutils.ibge.uf import convert_text_to_uf 147 | 148 | class TestUF(TestCase): 149 | def test_convert_text_to_uf(self): 150 | # Testes para nomes válidos 151 | self.assertEqual(convert_text_to_uf('São Paulo'), "SP") 152 | self.assertEqual(convert_text_to_uf('Rio de Janeiro'), "RJ") 153 | self.assertEqual(convert_text_to_uf('Minas Gerais'), "MG") 154 | self.assertEqual(convert_text_to_uf('Distrito Federal'), "DF") 155 | self.assertEqual(convert_text_to_uf('são paulo'), "SP") # Teste com minúsculas 156 | self.assertEqual(convert_text_to_uf('riO de janeiRo'), "RJ") # Teste com misturas de maiúsculas e minúsculas 157 | self.assertEqual(convert_text_to_uf('minas gerais'), "MG") # Teste com minúsculas 158 | self.assertEqual(convert_text_to_uf('sao paulo'), "SP") # Teste sem acento 159 | 160 | # Testes para nomes inválidos 161 | self.assertIsNone(convert_text_to_uf('Estado Inexistente')) # Nome não existe 162 | self.assertIsNone(convert_text_to_uf('')) # Nome vazio 163 | self.assertIsNone(convert_text_to_uf('123')) # Nome com números 164 | self.assertIsNone(convert_text_to_uf('São Paulo SP')) # Nome com sigla incluída 165 | self.assertIsNone(convert_text_to_uf('A')) # Nome com letra não mapeada 166 | self.assertIsNone(convert_text_to_uf('ZZZ')) # Nome com mais de 2 letras 167 | 168 | # implementar mais casos de teste aqui se necessário 169 | ``` 170 | 171 | 4. Seguir os passos seguintes do [guia de contribuição](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#primeira-contribui%C3%A7%C3%A3o). 172 | 173 | **Contexto adicional** 174 | 175 | * A lista de estados e suas siglas é definida pelo Instituto Brasileiro de Geografia e Estatística (IBGE). Para mais detalhes, consulte o [site do IBGE](https://atendimento.tecnospeed.com.br/hc/pt-br/articles/360021494734-Tabela-de-C%C3%B3digo-de-UF-do-IBGE). 176 | * A função deve lidar com a normalização de texto, incluindo a remoção de acentos e a conversão para minúsculas para garantir que o texto seja comparado de forma consistente. 177 | ``` 178 | -------------------------------------------------------------------------------- /brutils/cep.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | from random import randint 3 | from unicodedata import normalize 4 | from urllib.request import urlopen 5 | 6 | from brutils.data.enums import UF 7 | from brutils.exceptions import CEPNotFound, InvalidCEP 8 | from brutils.schemas import Address 9 | 10 | # FORMATTING 11 | ############ 12 | 13 | 14 | def format_cep(cep: str, only_nums=False) -> str | None: 15 | """ 16 | Formats a Brazilian CEP (Postal Code) into a standard format. 17 | 18 | This function takes a CEP (Postal Code) as input and, 19 | - Removes special characteres; 20 | - Check if the string follows the CEP length pattern; 21 | - Returns None if the string is out of the pattern; 22 | - Return a string with the formatted CEP. 23 | 24 | Args: 25 | cep (str): The input CEP (Postal Code) to be formatted. 26 | 27 | Returns: 28 | str: The formatted CEP in the "12345-678" format if it's valid, 29 | None if it's not valid. 30 | 31 | Example: 32 | >>> format_cep("12345678") 33 | "12345-678" 34 | >>> format_cep(" 12.345/678 ", only_nums=True) 35 | "12345678" 36 | >>> format_cep("12345") 37 | None 38 | """ 39 | ### Checking data type 40 | if not isinstance(cep, str): 41 | return None 42 | 43 | ### Removing special characteres 44 | cep = "".join(filter(str.isalnum, cep)) 45 | 46 | ### Checking CEP patterns 47 | if len(cep) != 8: 48 | return None 49 | 50 | ### Returning CEP value 51 | if only_nums: 52 | return cep 53 | else: 54 | return f"{cep[:5]}-{cep[5:]}" 55 | 56 | 57 | # OPERATIONS 58 | ############ 59 | 60 | 61 | def is_valid(cep: str) -> bool: 62 | """ 63 | Checks if a CEP (Postal Code) is valid. 64 | 65 | To be considered valid, the input must be a string containing exactly 8 66 | digits. 67 | This function does not verify if the CEP is a real postal code; it only 68 | validates the format of the string. 69 | 70 | Args: 71 | cep (str): The string containing the CEP to be checked. 72 | 73 | Returns: 74 | bool: True if the CEP is valid (8 digits), False otherwise. 75 | 76 | Example: 77 | >>> is_valid("12345678") 78 | True 79 | >>> is_valid("12345") 80 | False 81 | >>> is_valid("abcdefgh") 82 | False 83 | 84 | Source: 85 | https://en.wikipedia.org/wiki/Código_de_Endereçamento_Postal 86 | """ 87 | 88 | return isinstance(cep, str) and len(cep) == 8 and cep.isdigit() 89 | 90 | 91 | def generate() -> str: 92 | """ 93 | Generates a random 8-digit CEP (Postal Code) number as a string. 94 | 95 | Returns: 96 | str: A randomly generated 8-digit number. 97 | 98 | Example: 99 | >>> generate() 100 | "12345678" 101 | """ 102 | 103 | generated_number = "" 104 | 105 | for _ in range(8): 106 | generated_number = generated_number + str(randint(0, 9)) 107 | 108 | return generated_number 109 | 110 | 111 | # Reference: https://viacep.com.br/ 112 | def get_address_from_cep( 113 | cep: str, raise_exceptions: bool = False 114 | ) -> Address | None: 115 | """ 116 | Fetches address information from a given CEP (Postal Code) using the ViaCEP API. 117 | 118 | Args: 119 | cep (str): The CEP (Postal Code) to be used in the search. 120 | raise_exceptions (bool, optional): Whether to raise exceptions when the CEP is invalid or not found. Defaults to False. 121 | 122 | Raises: 123 | InvalidCEP: When the input CEP is invalid. 124 | CEPNotFound: When the input CEP is not found. 125 | 126 | Returns: 127 | Address | None: An Address object (TypedDict) containing the address information if the CEP is found, None otherwise. 128 | 129 | Example: 130 | >>> get_address_from_cep("12345678") 131 | { 132 | "cep": "12345-678", 133 | "logradouro": "Rua Example", 134 | "complemento": "", 135 | "bairro": "Example", 136 | "localidade": "Example", 137 | "uf": "EX", 138 | "ibge": "1234567", 139 | "gia": "1234", 140 | "ddd": "12", 141 | "siafi": "1234" 142 | } 143 | 144 | >>> get_address_from_cep("abcdefg") 145 | None 146 | 147 | >>> get_address_from_cep("abcdefg", True) 148 | InvalidCEP: CEP 'abcdefg' is invalid. 149 | 150 | >>> get_address_from_cep("00000000", True) 151 | CEPNotFound: 00000000 152 | """ 153 | base_api_url = "https://viacep.com.br/ws/{}/json/" 154 | 155 | clean_cep = format_cep(cep, only_nums=True) 156 | cep_is_valid = is_valid(clean_cep) 157 | 158 | if not cep_is_valid: 159 | if raise_exceptions: 160 | raise InvalidCEP(cep) 161 | 162 | return None 163 | 164 | try: 165 | with urlopen(base_api_url.format(clean_cep)) as f: 166 | response = f.read() 167 | data = loads(response) 168 | 169 | if data.get("erro", False): 170 | raise CEPNotFound(cep) 171 | 172 | return Address(**loads(response)) 173 | 174 | except Exception as e: 175 | if raise_exceptions: 176 | raise CEPNotFound(cep) from e 177 | 178 | return None 179 | 180 | 181 | def get_cep_information_from_address( 182 | federal_unit: str, city: str, street: str, raise_exceptions: bool = False 183 | ) -> list[Address] | None: 184 | """ 185 | Fetches CEP (Postal Code) options from a given address using the ViaCEP API. 186 | 187 | Args: 188 | federal_unit (str): The two-letter abbreviation of the Brazilian state. 189 | city (str): The name of the city. 190 | street (str): The name (or substring) of the street. 191 | raise_exceptions (bool, optional): Whether to raise exceptions when the address is invalid or not found. Defaults to False. 192 | 193 | Raises: 194 | ValueError: When the input UF is invalid. 195 | CEPNotFound: When the input address is not found. 196 | 197 | Returns: 198 | list[Address] | None: A list of Address objects (TypedDict) containing the address information if the address is found, None otherwise. 199 | 200 | Example: 201 | >>> get_cep_information_from_address("EX", "Example", "Rua Example") 202 | [ 203 | { 204 | "cep": "12345-678", 205 | "logradouro": "Rua Example", 206 | "complemento": "", 207 | "bairro": "Example", 208 | "localidade": "Example", 209 | "uf": "EX", 210 | "ibge": "1234567", 211 | "gia": "1234", 212 | "ddd": "12", 213 | "siafi": "1234" 214 | } 215 | ] 216 | 217 | >>> get_cep_information_from_address("A", "Example", "Rua Example") 218 | None 219 | 220 | >>> get_cep_information_from_address("XX", "Example", "Example", True) 221 | ValueError: Invalid UF: XX 222 | 223 | >>> get_cep_information_from_address("SP", "Example", "Example", True) 224 | CEPNotFound: SP - Example - Example 225 | """ 226 | if federal_unit in UF.values: 227 | federal_unit = UF(federal_unit).name 228 | 229 | if federal_unit not in UF.names: 230 | if raise_exceptions: 231 | raise ValueError(f"Invalid UF: {federal_unit}") 232 | 233 | return None 234 | 235 | base_api_url = "https://viacep.com.br/ws/{}/{}/{}/json/" 236 | 237 | parsed_city = ( 238 | normalize("NFD", city) 239 | .encode("ascii", "ignore") 240 | .decode("utf-8") 241 | .replace(" ", "%20") 242 | ) 243 | parsed_street = ( 244 | normalize("NFD", street) 245 | .encode("ascii", "ignore") 246 | .decode("utf-8") 247 | .replace(" ", "%20") 248 | ) 249 | 250 | try: 251 | with urlopen( 252 | base_api_url.format(federal_unit, parsed_city, parsed_street) 253 | ) as f: 254 | response = f.read() 255 | response = loads(response) 256 | 257 | if len(response) == 0: 258 | raise CEPNotFound(f"{federal_unit} - {city} - {street}") 259 | 260 | return [Address(**address) for address in response] 261 | 262 | except Exception as e: 263 | if raise_exceptions: 264 | raise CEPNotFound(f"{federal_unit} - {city} - {street}") from e 265 | 266 | return None 267 | -------------------------------------------------------------------------------- /tests/test_cep.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from unittest.mock import MagicMock, patch 3 | 4 | from brutils.cep import ( 5 | CEPNotFound, 6 | InvalidCEP, 7 | format_cep, 8 | generate, 9 | get_address_from_cep, 10 | get_cep_information_from_address, 11 | is_valid, 12 | ) 13 | 14 | 15 | class TestCEP(TestCase): 16 | def test_remove_symbols(self): 17 | self.assertEqual(format_cep("00000000"), "00000-000") 18 | self.assertEqual(format_cep("01310-200", only_nums=True), "01310200") 19 | self.assertEqual(format_cep("01..310.-200.-"), "01310-200") 20 | self.assertEqual(format_cep("abc01310200*!*&#"), None) 21 | self.assertEqual(format_cep("ab.c1.--.3-102.-0-.0-.*.-!*&#"), None) 22 | 23 | def test_is_valid(self): 24 | # When CEP is not string, returns False 25 | self.assertIs(is_valid(1), False) 26 | 27 | # When CEP's len is different of 8, returns False 28 | self.assertIs(is_valid("1"), False) 29 | 30 | # When CEP does not contain only digits, returns False 31 | self.assertIs(is_valid("1234567-"), False) 32 | 33 | # When CEP is valid 34 | self.assertIs(is_valid("99999999"), True) 35 | self.assertIs(is_valid("88390000"), True) 36 | 37 | def test_generate(self): 38 | for _ in range(10_000): 39 | self.assertIs(is_valid(generate()), True) 40 | 41 | 42 | @patch("brutils.cep.is_valid") 43 | class TestIsValidToFormat(TestCase): 44 | def test_when_cep_is_valid_returns_True_to_format(self, mock_is_valid): 45 | mock_is_valid.return_value = True 46 | 47 | self.assertEqual(format_cep("01310200"), "01310-200") 48 | 49 | def test_when_cep_is_not_valid_returns_error(self, mock_is_valid): 50 | mock_is_valid.return_value = False 51 | 52 | # When cep isn't valid, returns None 53 | self.assertEqual(format_cep("013102009"), None) 54 | 55 | 56 | @patch("brutils.cep.urlopen") 57 | class TestCEPAPICalls(TestCase): 58 | @patch("brutils.cep.loads") 59 | def test_get_address_from_cep_success(self, mock_loads, mock_urlopen): 60 | mock_loads.return_value = {"cep": "01310-200"} 61 | 62 | self.assertEqual( 63 | get_address_from_cep("01310200", True), {"cep": "01310-200"} 64 | ) 65 | 66 | def test_get_address_from_cep_raise_exception_invalid_cep( 67 | self, mock_urlopen 68 | ): 69 | mock_data = MagicMock() 70 | mock_data.read.return_value = {"erro": True} 71 | mock_urlopen.return_value = mock_data 72 | 73 | self.assertIsNone(get_address_from_cep("013102009")) 74 | 75 | def test_get_address_from_cep_invalid_cep_raise_exception_invalid_cep( 76 | self, mock_urlopen 77 | ): 78 | with self.assertRaises(InvalidCEP): 79 | get_address_from_cep("abcdef", True) 80 | 81 | def test_get_address_from_cep_invalid_cep_raise_exception_cep_not_found( 82 | self, mock_urlopen 83 | ): 84 | mock_data = MagicMock() 85 | mock_data.read.return_value = {"erro": True} 86 | mock_urlopen.return_value = mock_data 87 | 88 | with self.assertRaises(CEPNotFound): 89 | get_address_from_cep("01310209", True) 90 | 91 | @patch("brutils.cep.loads") 92 | def test_get_cep_information_from_address_success( 93 | self, mock_loads, mock_urlopen 94 | ): 95 | mock_loads.return_value = [{"cep": "01310-200"}] 96 | 97 | self.assertDictEqual( 98 | get_cep_information_from_address( 99 | "SP", "Example", "Rua Example", True 100 | )[0], 101 | {"cep": "01310-200"}, 102 | ) 103 | 104 | @patch("brutils.cep.loads") 105 | def test_get_cep_information_from_address_success_with_uf_conversion( 106 | self, mock_loads, mock_urlopen 107 | ): 108 | mock_loads.return_value = [{"cep": "01310-200"}] 109 | 110 | self.assertDictEqual( 111 | get_cep_information_from_address( 112 | "São Paulo", "Example", "Rua Example", True 113 | )[0], 114 | {"cep": "01310-200"}, 115 | ) 116 | 117 | @patch("brutils.cep.loads") 118 | def test_get_cep_information_from_address_empty_response( 119 | self, mock_loads, mock_urlopen 120 | ): 121 | mock_loads.return_value = [] 122 | 123 | self.assertIsNone( 124 | get_cep_information_from_address("SP", "Example", "Rua Example") 125 | ) 126 | 127 | @patch("brutils.cep.loads") 128 | def test_get_cep_information_from_address_raise_exception_invalid_cep( 129 | self, mock_loads, mock_urlopen 130 | ): 131 | mock_loads.return_value = {"erro": True} 132 | 133 | self.assertIsNone( 134 | get_cep_information_from_address("SP", "Example", "Rua Example") 135 | ) 136 | 137 | def test_get_cep_information_from_address_invalid_cep_dont_raise_exception_invalid_uf( 138 | self, mock_urlopen 139 | ): 140 | self.assertIsNone( 141 | get_cep_information_from_address("ABC", "Example", "Rua Example") 142 | ) 143 | 144 | def test_get_cep_information_from_address_invalid_cep_raise_exception_invalid_uf( 145 | self, mock_urlopen 146 | ): 147 | with self.assertRaises(ValueError): 148 | get_cep_information_from_address( 149 | "ABC", "Example", "Rua Example", True 150 | ) 151 | 152 | def test_get_cep_information_from_address_invalid_cep_raise_exception_cep_not_found( 153 | self, mock_urlopen 154 | ): 155 | mock_response = MagicMock() 156 | mock_response.read.return_value = {"erro": True} 157 | mock_urlopen.return_value = mock_response 158 | 159 | with self.assertRaises(CEPNotFound): 160 | get_cep_information_from_address( 161 | "SP", "Example", "Rua Example", True 162 | ) 163 | 164 | 165 | @patch("brutils.cep.urlopen") 166 | class TestGetAddressFromCEP_MC_DC(TestCase): 167 | def test_ct1_cep_invalido_com_excecao(self, mock_urlopen): 168 | """CT1: cep inválido e raise_exceptions=True → lança InvalidCEP""" 169 | with self.assertRaises(InvalidCEP): 170 | get_address_from_cep("abcdefg", raise_exceptions=True) 171 | 172 | def test_ct2_cep_invalido_sem_excecao(self, mock_urlopen): 173 | """CT2: cep inválido e raise_exceptions=False → retorna None""" 174 | result = get_address_from_cep("abcdefg", raise_exceptions=False) 175 | self.assertIsNone(result) 176 | 177 | @patch("brutils.cep.loads") 178 | def test_ct3_cep_valido(self, mock_loads, mock_urlopen): 179 | """CT3: cep válido → retorna dicionário com endereço""" 180 | 181 | # A função get_address_from_cep faz dois loads() no mesmo JSON 182 | mock_loads.side_effect = [ 183 | {"cep": "01001-000"}, # Primeiro loads para verificar "erro" 184 | {"cep": "01001-000"}, # Segundo loads para retornar Address 185 | ] 186 | 187 | # Simula a resposta da API ViaCEP 188 | mock_response = MagicMock() 189 | mock_response.read.return_value = b'{"cep": "01001-000"}' 190 | 191 | # Corrige o mock para suportar o contexto "with urlopen(...) as f" 192 | mock_urlopen.return_value.__enter__.return_value = mock_response 193 | 194 | # Executa a chamada 195 | result = get_address_from_cep("01001000", raise_exceptions=False) 196 | 197 | # Valida o resultado 198 | self.assertIsInstance(result, dict) 199 | self.assertEqual(result.get("cep"), "01001-000") 200 | 201 | def test_ct4_erro_na_api_com_excecao(self, mock_urlopen): 202 | """CT4: cep válido mas inexistente + raise_exceptions=True → lança CEPNotFound""" 203 | mock_response = MagicMock() 204 | mock_response.read.return_value = b'{"erro": true}' 205 | mock_urlopen.return_value = mock_response 206 | 207 | with self.assertRaises(CEPNotFound): 208 | get_address_from_cep("99999999", raise_exceptions=True) 209 | 210 | def test_ct5_erro_na_api_sem_excecao(self, mock_urlopen): 211 | """CT5: cep válido mas inexistente + raise_exceptions=False → retorna None""" 212 | mock_response = mock_urlopen.return_value 213 | mock_response.read.return_value = b'{"erro": true}' 214 | 215 | result = get_address_from_cep("99999999", raise_exceptions=False) 216 | self.assertIsNone(result) 217 | 218 | def test_ct6_viacep_responde_com_erro_e_raise_exceptions_true( 219 | self, mock_urlopen 220 | ): 221 | """ 222 | CT6: cobre a linha 172 e a decisão CD2 (data.get("erro", True)) 223 | """ 224 | mock_response = MagicMock() 225 | mock_response.read.return_value = b'{"erro": true}' 226 | mock_urlopen.return_value = mock_response 227 | 228 | with self.assertRaises(CEPNotFound): 229 | get_address_from_cep("01001000", raise_exceptions=True) 230 | 231 | 232 | if __name__ == "__main__": 233 | main() 234 | --------------------------------------------------------------------------------