Repository: brazilian-utils/brutils-python (python)

Issue:
Validar PIS
**Seu pedido de recurso está relacionado a um problema? Por favor, descreva.**
Verificar se uma String corresponde a um número de PIS válido, conforme as regras da numeração.  

**Descreva a solução que você gostaria**
Chamar um novo utilitário passando uma String e receber um boolean indicando se a String é um número de PIS válido ou não.

**Descreva alternativas que você considerou**
Criar um novo utilitário `is_valid_pis`.
```python
def is_valid_pis(pis):
...
```
Exemplo de uso:
```python
from brutils import is_valid_pis

>>> is_valid_pis("12062828707")
True
>>> is_valid_pis("12066181422")
True
>>> is_valid_pis("12066181427")
False
>>> is_valid_pis("1206282")
False
```

**Contexto adicional**
- Não é necessário validar se o PIS existe de fato;
- https://pt.wikipedia.org/wiki/PIS/PASEP
- https://www.macoratti.net/alg_pis.htm
- https://www.geradorpis.com/

Pull request:
feat: add is_valid_pis
## Descrição
Adiciona suporte à validação de números PIS/PASEP.

## Mudanças Propostas
<!--- Liste as principais alterações ou melhorias que estão sendo propostas neste Pull Request.

- Exemplo 1: Descrição da primeira alteração.
- Exemplo 2: Descrição da segunda alteração.
- ...-->
Implementação das funções de validação de números PIS/PASEP, com funções concisas que darão suporte à implementação de novas funcionalidades como a geração de números PIS aleatórios.


## Checklist de Revisão
<!--- Marque as caixas que se aplicam. Você pode deixar caixas desmarcadas se elas não se aplicarem.-->

- [x] Eu li o [Contributing.md](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md)
- [x] Os testes foram adicionados ou atualizados para refletir as mudanças (se aplicável).
- [x] Foi adicionada uma entrada no changelog / Meu PR não necessita de uma nova entrada no changelog.
- [x] A [documentação](https://github.com/brazilian-utils/brutils-python/blob/main/README.md) em português foi atualizada ou criada, se necessário.
- [x] 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). <!---Permitido uso de Google Tradutor/ChatGPT. -->
- [x] 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)
- [x] O código segue as diretrizes de estilo e padrões de codificação do projeto.
- [x] Todos os testes passam. [Instruções](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#testes)
- [x] O Pull Request foi testado localmente. [Instruções](https://github.com/brazilian-utils/brutils-python/blob/main/CONTRIBUTING.md#7-execute-o-brutils-localmente)
- [x] Não há conflitos de mesclagem.


## Comentários Adicionais (opcional)
<!--- Adicione qualquer informação adicional ou contexto que você acha importante para os revisores entenderem suas mudanças.-->
- Adicionei type hints nas minhas funções pelo hábito de trabalhar com TypeScript :)

## Issue Relacionada
<!---Todos os PRs devem ter uma issue relacionada. Dessa forma, podemos garantir que ninguém perca tempo trabalhando em algo que não precisa ser feito. -->


Closes #203 


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fdee78..ca57531 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- Utilitário `is_valid_pis` [#216](https://github.com/brazilian-utils/brutils-python/pull/216)
 - Utilitário `is_valid_email` [#213](https://github.com/brazilian-utils/brutils-python/pull/213)
 - Utilitário `iis_valid_license_plate_old_format` [#174](https://github.com/brazilian-utils/brutils-python/pull/174)
 - Utilitário `is_valid_license_plate_old_format` [#174](https://github.com/brazilian-utils/brutils-python/pull/174)
diff --git a/README.md b/README.md
index 8999384..c84c2ac 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,8 @@ False
   - [is_valid_license_plate_old_format](#is_valid_license_plate_old_format)
 - [License Plate](#license_plate)
   - [is_valid_license_plate_mercosul](#is_valid_license_plate_mercosul)
+- [PIS](#pis)
+  - [is_valid_pis](#is_valid_pis)
 
 ## CPF
 
@@ -316,6 +318,24 @@ normas do Mercosul, isto é, seguindo o padrão LLLNLNN.
 True
 ```
 
+## PIS
+
+### is_valid_pis
+
+Verifica se o número PIS/PASEP é valido. Apenas números, formatados como string. Não verifica se o PIS/PASEP existe.
+Mais detalhes sobre a validação estão disponíveis em https://www.macoratti.net/alg_pis.htm.
+
+```python
+from brutils import is_valid_pis
+
+>>> is_valid_pis("12038619494")
+True
+>>> is_valid_pis("11111111111")
+False
+>>> is_valid_pis("123456")
+False
+```
+
 # Novos Utilitários e Reportar Bugs
 
 Caso queira sugerir novas funcionalidades ou reportar bugs, basta criar
diff --git a/README_EN.md b/README_EN.md
index d0d69fb..4adf5b0 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -68,6 +68,8 @@ False
   - [is_valid_license_plate_old_format](#is_valid_license_plate_old_format)
 - [License Plate](#license_plate)
   - [is_valid_license_plate_mercosul](#is_valid_license_plate_mercosul)
+- [PIS](#pis)
+  - [is_valid_pis](#is_valid_pis)
 
 
 ## CPF
@@ -291,6 +293,24 @@ Mercosul standards, in other words, if it follows the pattern LLLNLNN.
 True
 ```
 
+## PIS
+
+### is_valid_pis
+
+Check if PIS/PASEP number is valid. Numbers only, formatted as strings. Does not check if PIS/PASEP exists.
+More details about the validation can be found here: https://www.macoratti.net/alg_pis.htm.
+
+```python
+from brutils import is_valid_pis
+
+>>> is_valid_pis("12038619494")
+True
+>>> is_valid_pis("11111111111")
+False
+>>> is_valid_pis("123456")
+False
+```
+
 # Feature Request and Bug Report
 
 If you want to suggest new features or report bugs, simply create
diff --git a/brutils/__init__.py b/brutils/__init__.py
index a50b1a2..8bc0b0d 100644
--- a/brutils/__init__.py
+++ b/brutils/__init__.py
@@ -27,4 +27,9 @@ from brutils.license_plate import (
 )
 
 from brutils.email import is_valid as is_valid_email
+
 from brutils.license_plate import is_valid_license_plate_old_format
+
+from brutils.pis import (
+    is_valid as is_valid_pis,
+)
diff --git a/brutils/pis.py b/brutils/pis.py
new file mode 100644
index 0000000..ddb213b
--- /dev/null
+++ b/brutils/pis.py
@@ -0,0 +1,57 @@
+WEIGHTS = [3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
+
+
+# OPERATIONS
+############
+
+
+def validate(pis: str) -> bool:
+    """
+    Validate a Brazilian PIS number.
+
+    The PIS is valid if:
+    - It has 11 digits
+    - All characters are digits
+    - It passes the weight calculation check
+
+    Args:
+        pis[str]: PIS number as a string.
+
+    Returns:
+        value[bool]: True if PIS is valid, False otherwise.
+    """
+    if len(pis) != 11 or not pis.isdigit():
+        return False
+
+    return pis[-1] == str(_checksum(pis[:-1]))
+
+
+def is_valid(pis: str) -> bool:
+    """
+    Returns whether or not the verifying checksum digit of the
+    given `pis` match its base number.
+
+    Args:
+        pis[str]: PIS number as a string of proper length.
+
+    Returns:
+        value[bool]: True if PIS is valid, False otherwise.
+    """
+    return isinstance(pis, str) and validate(pis)
+
+
+def _checksum(base_pis: str) -> int:
+    """
+    Calculates the checksum digit of the given `base_pis` string.
+
+    Args:
+        base_pis [str]: 10 first digits of PIS number as a string.
+
+    Returns:
+        value [int]: Checksum digit.
+    """
+    pis_digits = list(map(int, base_pis))
+    pis_sum = sum(digit * weight for digit, weight in zip(pis_digits, WEIGHTS))
+    check_digit = 11 - (pis_sum % 11)
+
+    return 0 if check_digit in [10, 11] else check_digit


Test patch:
diff --git a/tests/test_pis.py b/tests/test_pis.py
new file mode 100644
index 0000000..bd81287
--- /dev/null
+++ b/tests/test_pis.py
@@ -0,0 +1,58 @@
+from os import pardir
+from os.path import abspath, join, dirname
+from sys import path, version_info, dont_write_bytecode
+from inspect import getsourcefile
+from unittest.mock import patch
+
+dont_write_bytecode = True
+range = range if version_info.major >= 3 else xrange
+path.insert(
+    1, abspath(join(dirname(abspath(getsourcefile(lambda: 0))), pardir))
+)
+from brutils.pis import validate, is_valid, _checksum
+from unittest import TestCase, main
+
+
+class TestPIS(TestCase):
+    def test_validate(self):
+        self.assertIs(validate("123456789ab"), False)
+        self.assertIs(validate("901.85930.32-3"), False)
+        self.assertIs(validate("90185930323"), True)
+        self.assertIs(validate("93616217820"), True)
+
+    def test_is_valid(self):
+        # When PIS is not string, returns False
+        self.assertIs(is_valid(1), False)
+        self.assertIs(is_valid([]), False)
+        self.assertIs(is_valid({}), False)
+        self.assertIs(is_valid(None), False)
+
+        # When PIS's len is different of 11, returns False
+        self.assertIs(is_valid("123456789"), False)
+
+        # When PIS does not contain only digits, returns False
+        self.assertIs(is_valid("123pis"), False)
+
+        # When checksum digit doesn't match last digit, returns False
+        self.assertIs(is_valid("11111111111"), False)
+        self.assertIs(is_valid("11111111215"), False)
+        self.assertIs(is_valid("12038619493"), False)
+
+        # When PIS is valid
+        self.assertIs(is_valid("12038619494"), True)
+        self.assertIs(is_valid("12016784018"), True)
+        self.assertIs(is_valid("12083210826"), True)
+
+    def test_checksum(self):
+        # Checksum digit is 0 when the subtracted number is 10 or 11
+        self.assertEqual(_checksum("1204152015"), 0)
+        self.assertEqual(_checksum("1204433157"), 0)
+
+        # Checksum digit is equal the subtracted number
+        self.assertEqual(_checksum("1204917738"), 2)
+        self.assertEqual(_checksum("1203861949"), 4)
+        self.assertEqual(_checksum("1208321082"), 6)
+
+
+if __name__ == "__main__":
+    main()