faturapt 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
18
+ steps:
19
+ - uses: actions/checkout@v5
20
+ - uses: astral-sh/setup-uv@v6
21
+ with:
22
+ python-version: ${{ matrix.python }}
23
+ - run: uv sync --locked --all-extras
24
+ - run: uv run ruff check .
25
+ - run: uv run ruff format --check .
26
+ - run: uv run mypy
27
+ - run: uv run pytest
28
+ - run: uv build
@@ -0,0 +1,40 @@
1
+ name: Release
2
+
3
+ # Publica no PyPI quando se cria uma tag vX.Y.Z. Usa trusted publishing: no PyPI,
4
+ # registar este repositório, o workflow release.yml e o ambiente "pypi".
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+ - uses: astral-sh/setup-uv@v6
18
+ - name: A tag tem de ser igual à versão
19
+ run: |
20
+ version=$(uv run --no-project python -c "import re;print(re.search(r'__version__ = \"(.+)\"', open('src/faturapt/__init__.py').read())[1])")
21
+ test "v$version" = "$GITHUB_REF_NAME"
22
+ - run: uv sync --locked --all-extras && uv run pytest
23
+ - run: uv build
24
+ - uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ environment: pypi
33
+ permissions:
34
+ id-token: write
35
+ steps:
36
+ - uses: actions/download-artifact@v4
37
+ with:
38
+ name: dist
39
+ path: dist/
40
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .coverage
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .hypothesis/
@@ -0,0 +1,21 @@
1
+ # Alterações
2
+
3
+ O formato segue o [Keep a Changelog](https://keepachangelog.com/pt-PT/1.1.0/) e as versões o [Semantic Versioning](https://semver.org/lang/pt-PT/).
4
+
5
+ ## [Por publicar]
6
+
7
+ ## [0.1.0] - 2026-10-21
8
+
9
+ Primeira versão.
10
+
11
+ ### Adicionado
12
+
13
+ - `parse`, para ler o texto do código QR das faturas (Portaria n.º 195/2020), e `check`, para confirmar o conteúdo: ordem dos campos, duas casas decimais, NIFs, ATCUD (o número no fim tem de ser o do documento, e o documento em G tem de ser do tipo D) e contas.
14
+ - `InvoiceQR.to_text()`, para gerar o texto do QR, com testes de ida e volta (Hypothesis).
15
+ - `read_image`, para encontrar e ler o QR numa fotografia ou digitalização, com as dependências opcionais `faturapt[image]` (Pillow e zxing-cpp).
16
+ - `is_valid_nif`, `nif_type` e `normalize_nif`.
17
+ - Linha de comandos `faturapt`, a partir do texto ou de uma imagem (`-i`/`--imagem`).
18
+ - Regras conferidas com as especificações técnicas da AT (versão 1.0, agosto de 2020), com os quatro exemplos do documento nos testes.
19
+
20
+ [Por publicar]: https://github.com/JoseFerrao20/faturapt/compare/v0.1.0...HEAD
21
+ [0.1.0]: https://github.com/JoseFerrao20/faturapt/releases/tag/v0.1.0
@@ -0,0 +1,22 @@
1
+ # faturapt
2
+
3
+ Biblioteca Python (≥ 3.10, sem dependências) que lê e valida o código QR das faturas portuguesas (`src/faturapt/qr.py`) e NIFs (`src/faturapt/nif.py`), com a linha de comandos em `__main__.py`. A leitura de imagens (`image.py`) usa as dependências opcionais `faturapt[image]` (Pillow e zxing-cpp), importadas só dentro do `read_image`: `import faturapt` tem de funcionar sem elas. Projeto de portfólio de José Ferrão.
4
+
5
+ ## Comandos
6
+
7
+ ```bash
8
+ uv sync --all-extras
9
+ uv run pytest # inclui os exemplos do README (doctest) e cobertura ≥ 95 %
10
+ uv run ruff check . && uv run ruff format --check . && uv run mypy
11
+ uv build
12
+ ```
13
+
14
+ ## Convenções
15
+
16
+ - README, CHANGELOG, mensagens de erro e descrições de PR em **português de Portugal**; nomes no código e mensagens de commit em inglês.
17
+ - API pública só pelo que está em `faturapt.__all__`; mudar algo que já lá está é uma versão nova *major* (ou *minor* antes da 1.0) e entra no `CHANGELOG.md`.
18
+ - `parse` só recusa o que está mal formado; problemas de conteúdo (NIFs, contas) vão para `check`, que devolve uma lista e nunca lança.
19
+ - Valores em `Decimal`, nunca `float`.
20
+ - Nos testes de imagem, os QR são gerados com o `segno` e não com o escritor do zxing, para o leitor não ser testado contra si próprio.
21
+ - `to_text` e `parse` são inversos: `parse(fatura.to_text()) == fatura` (teste com o Hypothesis em `tests/test_build.py`). Um campo novo entra nos dois.
22
+ - Cada alteração num PR a partir de um ramo, com o CI verde.
faturapt-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 José Ferrão
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.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.5
2
+ Name: faturapt
3
+ Version: 0.1.0
4
+ Summary: Leitura e validação do código QR das faturas portuguesas (AT) e de NIFs.
5
+ Project-URL: Repository, https://github.com/JoseFerrao20/faturapt
6
+ Project-URL: Changelog, https://github.com/JoseFerrao20/faturapt/blob/main/CHANGELOG.md
7
+ Author: José Ferrão
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: atcud,autoridade tributária,fatura,invoice,nif,portugal,qr code
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: Portuguese
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Provides-Extra: image
26
+ Requires-Dist: pillow>=10; extra == 'image'
27
+ Requires-Dist: zxing-cpp>=3.0; extra == 'image'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # faturapt
31
+
32
+ [![CI](https://github.com/JoseFerrao20/faturapt/actions/workflows/ci.yml/badge.svg)](https://github.com/JoseFerrao20/faturapt/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/faturapt)](https://pypi.org/project/faturapt/)
34
+ [![Versões de Python](https://img.shields.io/pypi/pyversions/faturapt)](https://pypi.org/project/faturapt/)
35
+ [![Licença: MIT](https://img.shields.io/badge/licen%C3%A7a-MIT-blue)](LICENSE)
36
+
37
+ Lê e valida o **código QR das faturas portuguesas** (o que a AT exige desde 2022, Portaria n.º 195/2020) e **NIFs**. Python puro, sem dependências obrigatórias, com tipos.
38
+
39
+ ```python
40
+ >>> import faturapt
41
+ >>> inv = faturapt.parse("A:500000000*B:999999990*C:PT*D:FS*E:N*F:20261015*G:FS 01/1234"
42
+ ... "*H:JJ3XY2KD-1234*I1:PT*I7:10.00*I8:2.30*N:2.30*O:12.30*Q:Ab1z*R:1234")
43
+ >>> inv.document_type_name, inv.document_date, inv.total
44
+ ('Fatura simplificada', datetime.date(2026, 10, 15), Decimal('12.30'))
45
+ >>> inv.is_final_consumer
46
+ True
47
+ >>> faturapt.check(inv) # NIFs e contas; lista vazia = tudo certo
48
+ []
49
+ >>> faturapt.nif_type("PT 123 456 789")
50
+ <NifType.INDIVIDUAL: 'pessoa singular'>
51
+
52
+ ```
53
+
54
+ ## Instalar
55
+
56
+ ```bash
57
+ pip install faturapt # só texto: sem dependências
58
+ pip install 'faturapt[image]' # também lê fotografias (Pillow e zxing-cpp)
59
+ ```
60
+
61
+ ```python
62
+ >>> fatura = faturapt.read_image("talao.jpg") # doctest: +SKIP
63
+ >>> fatura.total, faturapt.check(fatura) # doctest: +SKIP
64
+ (Decimal('12.30'), [])
65
+ ```
66
+
67
+ ## O que faz
68
+
69
+ - **`parse(texto)`**: converte o texto do QR num `InvoiceQR` (datas em `date`, valores em `Decimal`, IVA por espaço fiscal: continente, Açores e Madeira). Lança `QRCodeError`, com a mensagem em português, se faltarem campos obrigatórios, houver campos repetidos, desconhecidos ou acima do tamanho máximo, ou valores mal formados.
70
+ - **`check(fatura)`**: devolve os problemas de conteúdo: campos fora de ordem, valores sem duas casas decimais, NIFs inválidos, ATCUD que não acaba no número do documento (G) ou documento (G) de outro tipo que não o D, total de impostos (N) diferente de IVA + selo, ou total (O) diferente de bases + não sujeito + impostos.
71
+ - **`fatura.to_text()`**: o caminho inverso, para quem gera faturas: escreve o texto do QR pela ordem da especificação, com duas casas decimais, e recusa valores acima do tamanho máximo. `parse(fatura.to_text())` devolve sempre a mesma fatura (os testes confirmam-no com o Hypothesis, em faturas geradas ao acaso).
72
+ - **`is_valid_nif`, `nif_type`, `normalize_nif`**: dígito de controlo, prefixos e tipo de contribuinte (pessoa singular, coletiva, condomínio, …). Aceita espaços, pontos e o prefixo `PT`.
73
+ - **`read_image(imagem)`**: encontra o QR da fatura numa fotografia ou digitalização (caminho, `bytes` ou imagem do Pillow) e lê-o com o `parse`. Respeita a orientação EXIF do telemóvel e, se houver outros QR na imagem (um site, uma promoção), escolhe o da fatura. Precisa de `faturapt[image]`.
74
+ - **Linha de comandos**: `faturapt "A:...*B:..."` (ou pelo stdin) ou `faturapt -i talao.jpg` mostra a fatura em JSON. Sai com 0 se estiver tudo certo, com 1 se houver problemas e com 2 se o texto ou a imagem não tiverem um QR válido.
75
+
76
+ As regras seguem as [especificações técnicas da AT](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/Novas_regras_faturacao/Documents/Especificacoes_Tecnicas_Codigo_QR.pdf) (versão 1.0, agosto de 2020), e os quatro exemplos do documento fazem parte dos testes. O `parse` recusa o que não se consegue ler (campos desconhecidos, repetidos ou acima do tamanho máximo, obrigatórios em falta); o `check` aponta o resto (campos fora de ordem, valores sem duas casas decimais, NIFs e contas).
77
+
78
+
79
+ ## Campos do QR
80
+
81
+ | Chave | Campo | `InvoiceQR` |
82
+ |---|---|---|
83
+ | A, B, C | NIF do emitente, NIF e país do adquirente | `issuer_nif`, `buyer_nif`, `buyer_country` |
84
+ | D, E, F, G | Tipo, estado, data e identificação do documento | `document_type`, `document_status`, `document_date`, `document_id` |
85
+ | H | ATCUD | `atcud` |
86
+ | I1–I8, J1–J8, K1–K8 | IVA por espaço fiscal (isento, reduzida, intermédia, normal) | `taxes` (`TaxBreakdown`) |
87
+ | L, M, N, O, P | Não sujeito, selo, total de impostos, total, retenção na fonte | `non_taxable`, `stamp_duty`, `total_taxes`, `total`, `withholding` |
88
+ | Q, R, S | 4 caracteres do hash, n.º do certificado, outras informações | `hash_chars`, `certificate`, `other_info` |
89
+
90
+ ## Desenvolvimento
91
+
92
+ ```bash
93
+ uv sync --all-extras # ambiente com as dependências de desenvolvimento e de imagem
94
+ uv run pytest # testes, com cobertura mínima de 95 %
95
+ uv run ruff check . && uv run ruff format --check . && uv run mypy
96
+ ```
97
+
98
+ O CI corre o mesmo em Python 3.10 a 3.14. Para publicar uma versão: atualizar `__version__`, passar as entradas de "Por publicar" do `CHANGELOG.md` para a versão nova, com a data, e criar uma tag `vX.Y.Z`. O workflow `release.yml` publica no PyPI por *trusted publishing* (sem tokens guardados no GitHub).
99
+
100
+ ## Licença
101
+
102
+ MIT.
@@ -0,0 +1,73 @@
1
+ # faturapt
2
+
3
+ [![CI](https://github.com/JoseFerrao20/faturapt/actions/workflows/ci.yml/badge.svg)](https://github.com/JoseFerrao20/faturapt/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/faturapt)](https://pypi.org/project/faturapt/)
5
+ [![Versões de Python](https://img.shields.io/pypi/pyversions/faturapt)](https://pypi.org/project/faturapt/)
6
+ [![Licença: MIT](https://img.shields.io/badge/licen%C3%A7a-MIT-blue)](LICENSE)
7
+
8
+ Lê e valida o **código QR das faturas portuguesas** (o que a AT exige desde 2022, Portaria n.º 195/2020) e **NIFs**. Python puro, sem dependências obrigatórias, com tipos.
9
+
10
+ ```python
11
+ >>> import faturapt
12
+ >>> inv = faturapt.parse("A:500000000*B:999999990*C:PT*D:FS*E:N*F:20261015*G:FS 01/1234"
13
+ ... "*H:JJ3XY2KD-1234*I1:PT*I7:10.00*I8:2.30*N:2.30*O:12.30*Q:Ab1z*R:1234")
14
+ >>> inv.document_type_name, inv.document_date, inv.total
15
+ ('Fatura simplificada', datetime.date(2026, 10, 15), Decimal('12.30'))
16
+ >>> inv.is_final_consumer
17
+ True
18
+ >>> faturapt.check(inv) # NIFs e contas; lista vazia = tudo certo
19
+ []
20
+ >>> faturapt.nif_type("PT 123 456 789")
21
+ <NifType.INDIVIDUAL: 'pessoa singular'>
22
+
23
+ ```
24
+
25
+ ## Instalar
26
+
27
+ ```bash
28
+ pip install faturapt # só texto: sem dependências
29
+ pip install 'faturapt[image]' # também lê fotografias (Pillow e zxing-cpp)
30
+ ```
31
+
32
+ ```python
33
+ >>> fatura = faturapt.read_image("talao.jpg") # doctest: +SKIP
34
+ >>> fatura.total, faturapt.check(fatura) # doctest: +SKIP
35
+ (Decimal('12.30'), [])
36
+ ```
37
+
38
+ ## O que faz
39
+
40
+ - **`parse(texto)`**: converte o texto do QR num `InvoiceQR` (datas em `date`, valores em `Decimal`, IVA por espaço fiscal: continente, Açores e Madeira). Lança `QRCodeError`, com a mensagem em português, se faltarem campos obrigatórios, houver campos repetidos, desconhecidos ou acima do tamanho máximo, ou valores mal formados.
41
+ - **`check(fatura)`**: devolve os problemas de conteúdo: campos fora de ordem, valores sem duas casas decimais, NIFs inválidos, ATCUD que não acaba no número do documento (G) ou documento (G) de outro tipo que não o D, total de impostos (N) diferente de IVA + selo, ou total (O) diferente de bases + não sujeito + impostos.
42
+ - **`fatura.to_text()`**: o caminho inverso, para quem gera faturas: escreve o texto do QR pela ordem da especificação, com duas casas decimais, e recusa valores acima do tamanho máximo. `parse(fatura.to_text())` devolve sempre a mesma fatura (os testes confirmam-no com o Hypothesis, em faturas geradas ao acaso).
43
+ - **`is_valid_nif`, `nif_type`, `normalize_nif`**: dígito de controlo, prefixos e tipo de contribuinte (pessoa singular, coletiva, condomínio, …). Aceita espaços, pontos e o prefixo `PT`.
44
+ - **`read_image(imagem)`**: encontra o QR da fatura numa fotografia ou digitalização (caminho, `bytes` ou imagem do Pillow) e lê-o com o `parse`. Respeita a orientação EXIF do telemóvel e, se houver outros QR na imagem (um site, uma promoção), escolhe o da fatura. Precisa de `faturapt[image]`.
45
+ - **Linha de comandos**: `faturapt "A:...*B:..."` (ou pelo stdin) ou `faturapt -i talao.jpg` mostra a fatura em JSON. Sai com 0 se estiver tudo certo, com 1 se houver problemas e com 2 se o texto ou a imagem não tiverem um QR válido.
46
+
47
+ As regras seguem as [especificações técnicas da AT](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/Novas_regras_faturacao/Documents/Especificacoes_Tecnicas_Codigo_QR.pdf) (versão 1.0, agosto de 2020), e os quatro exemplos do documento fazem parte dos testes. O `parse` recusa o que não se consegue ler (campos desconhecidos, repetidos ou acima do tamanho máximo, obrigatórios em falta); o `check` aponta o resto (campos fora de ordem, valores sem duas casas decimais, NIFs e contas).
48
+
49
+
50
+ ## Campos do QR
51
+
52
+ | Chave | Campo | `InvoiceQR` |
53
+ |---|---|---|
54
+ | A, B, C | NIF do emitente, NIF e país do adquirente | `issuer_nif`, `buyer_nif`, `buyer_country` |
55
+ | D, E, F, G | Tipo, estado, data e identificação do documento | `document_type`, `document_status`, `document_date`, `document_id` |
56
+ | H | ATCUD | `atcud` |
57
+ | I1–I8, J1–J8, K1–K8 | IVA por espaço fiscal (isento, reduzida, intermédia, normal) | `taxes` (`TaxBreakdown`) |
58
+ | L, M, N, O, P | Não sujeito, selo, total de impostos, total, retenção na fonte | `non_taxable`, `stamp_duty`, `total_taxes`, `total`, `withholding` |
59
+ | Q, R, S | 4 caracteres do hash, n.º do certificado, outras informações | `hash_chars`, `certificate`, `other_info` |
60
+
61
+ ## Desenvolvimento
62
+
63
+ ```bash
64
+ uv sync --all-extras # ambiente com as dependências de desenvolvimento e de imagem
65
+ uv run pytest # testes, com cobertura mínima de 95 %
66
+ uv run ruff check . && uv run ruff format --check . && uv run mypy
67
+ ```
68
+
69
+ O CI corre o mesmo em Python 3.10 a 3.14. Para publicar uma versão: atualizar `__version__`, passar as entradas de "Por publicar" do `CHANGELOG.md` para a versão nova, com a data, e criar uma tag `vX.Y.Z`. O workflow `release.yml` publica no PyPI por *trusted publishing* (sem tokens guardados no GitHub).
70
+
71
+ ## Licença
72
+
73
+ MIT.
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "faturapt"
7
+ dynamic = ["version"]
8
+ description = "Leitura e validação do código QR das faturas portuguesas (AT) e de NIFs."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "José Ferrão" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = []
15
+ keywords = ["portugal", "fatura", "invoice", "qr code", "nif", "atcud", "autoridade tributária"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Natural Language :: Portuguese",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Office/Business :: Financial :: Accounting",
29
+ "Typing :: Typed",
30
+ ]
31
+
32
+ [project.urls]
33
+ Repository = "https://github.com/JoseFerrao20/faturapt"
34
+ Changelog = "https://github.com/JoseFerrao20/faturapt/blob/main/CHANGELOG.md"
35
+
36
+ [project.scripts]
37
+ faturapt = "faturapt.__main__:main"
38
+
39
+ [project.optional-dependencies]
40
+ image = [
41
+ "pillow>=10",
42
+ "zxing-cpp>=3.0",
43
+ ]
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pytest>=8",
48
+ "pytest-cov>=5",
49
+ "ruff>=0.6",
50
+ "mypy>=1.11",
51
+ "hypothesis>=6.100",
52
+ "segno>=1.6.6",
53
+ ]
54
+
55
+ [tool.hatch.version]
56
+ path = "src/faturapt/__init__.py"
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+ target-version = "py310"
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "F", "W", "I", "B", "UP", "S", "SIM", "RUF"]
64
+
65
+ [tool.ruff.lint.isort]
66
+ known-local-folder = ["test_qr"] # os testes importam os exemplos uns dos outros
67
+
68
+ [tool.ruff.lint.per-file-ignores]
69
+ "tests/**" = ["S101", "E402"] # assert nos testes; imports depois do importorskip
70
+
71
+ [tool.mypy]
72
+ strict = true
73
+ files = ["src", "tests"]
74
+
75
+ [tool.pytest.ini_options]
76
+ addopts = "--doctest-glob=README.md --cov=faturapt --cov-report=term-missing --cov-fail-under=95"
@@ -0,0 +1,22 @@
1
+ """Leitura e validação do código QR das faturas portuguesas e de NIFs."""
2
+
3
+ from .image import read_image
4
+ from .nif import InvalidNIF, NifType, is_valid_nif, nif_type, normalize_nif
5
+ from .qr import DOCUMENT_TYPES, InvoiceQR, QRCodeError, TaxBreakdown, check, parse
6
+
7
+ __all__ = [
8
+ "DOCUMENT_TYPES",
9
+ "InvalidNIF",
10
+ "InvoiceQR",
11
+ "NifType",
12
+ "QRCodeError",
13
+ "TaxBreakdown",
14
+ "check",
15
+ "is_valid_nif",
16
+ "nif_type",
17
+ "normalize_nif",
18
+ "parse",
19
+ "read_image",
20
+ ]
21
+
22
+ __version__ = "0.1.0"
@@ -0,0 +1,53 @@
1
+ """``faturapt "A:...*B:..."`` ou ``faturapt -i talao.jpg``: mostra a fatura em JSON e os
2
+ problemas encontrados."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from dataclasses import asdict
10
+ from datetime import date
11
+ from decimal import Decimal
12
+ from typing import Any
13
+
14
+ from .image import read_image
15
+ from .qr import QRCodeError, check, parse
16
+
17
+
18
+ def _json(value: Any) -> Any:
19
+ if isinstance(value, Decimal):
20
+ return str(value)
21
+ if isinstance(value, date):
22
+ return value.isoformat()
23
+ raise TypeError(type(value).__name__)
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ parser = argparse.ArgumentParser(prog="faturapt", description=__doc__)
28
+ source = parser.add_mutually_exclusive_group()
29
+ source.add_argument("qr", nargs="?", help="texto do código QR (por omissão, lido do stdin)")
30
+ source.add_argument(
31
+ "-i", "--imagem", help="fotografia ou digitalização da fatura (precisa de faturapt[image])"
32
+ )
33
+ args = parser.parse_args(argv)
34
+
35
+ try:
36
+ if args.imagem is not None:
37
+ invoice = read_image(args.imagem)
38
+ else:
39
+ invoice = parse(args.qr if args.qr is not None else sys.stdin.read())
40
+ except (QRCodeError, ImportError, OSError) as exc:
41
+ print(f"erro: {exc}", file=sys.stderr)
42
+ return 2
43
+
44
+ data = asdict(invoice)
45
+ data.pop("fields")
46
+ data["document_type_name"] = invoice.document_type_name
47
+ data["problems"] = check(invoice)
48
+ print(json.dumps(data, default=_json, ensure_ascii=False, indent=2))
49
+ return 1 if data["problems"] else 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
@@ -0,0 +1,61 @@
1
+ """Leitura do código QR a partir de uma imagem (fotografia ou digitalização da fatura).
2
+
3
+ Precisa das dependências opcionais: ``pip install 'faturapt[image]'``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import io
9
+ import os
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .qr import InvoiceQR, QRCodeError, parse
13
+
14
+ if TYPE_CHECKING:
15
+ from PIL.Image import Image
16
+
17
+ __all__ = ["read_image"]
18
+
19
+ _MISSING = "Para ler imagens, instalar com: pip install 'faturapt[image]'"
20
+
21
+
22
+ def read_image(image: str | os.PathLike[str] | bytes | Image) -> InvoiceQR:
23
+ """Encontra o código QR da fatura numa imagem e lê-o com :func:`parse`.
24
+
25
+ Aceita o caminho de um ficheiro, o conteúdo em ``bytes`` ou uma imagem do Pillow.
26
+ Respeita a orientação EXIF das fotografias do telemóvel. Se houver vários códigos
27
+ (por exemplo, o QR de um site ao lado), devolve o primeiro que seja de uma fatura.
28
+
29
+ Lança :class:`QRCodeError` se não houver nenhum QR de fatura na imagem, e
30
+ ``ImportError`` se faltarem as dependências opcionais.
31
+ """
32
+ try:
33
+ import zxingcpp
34
+ from PIL import Image as PILImage
35
+ from PIL import ImageOps
36
+ except ImportError as exc:
37
+ raise ImportError(_MISSING) from exc
38
+
39
+ picture: Image
40
+ if isinstance(image, bytes):
41
+ picture = PILImage.open(io.BytesIO(image))
42
+ elif isinstance(image, (str, os.PathLike)):
43
+ picture = PILImage.open(image)
44
+ else:
45
+ picture = image
46
+ picture = ImageOps.exif_transpose(picture).convert("L")
47
+
48
+ codes = zxingcpp.read_barcodes(
49
+ picture, formats=zxingcpp.BarcodeFormats(zxingcpp.BarcodeFormat.QRCode)
50
+ )
51
+ if not codes:
52
+ raise QRCodeError("Não encontrei nenhum código QR na imagem")
53
+ errors = []
54
+ for code in codes:
55
+ try:
56
+ return parse(code.text)
57
+ except QRCodeError as exc:
58
+ errors.append(str(exc))
59
+ raise QRCodeError(
60
+ f"Há {len(codes)} código(s) QR na imagem, mas nenhum é de uma fatura ({errors[0]})"
61
+ )
@@ -0,0 +1,91 @@
1
+ """Validação do NIF (Número de Identificação Fiscal) português."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ __all__ = ["InvalidNIF", "NifType", "is_valid_nif", "nif_type", "normalize_nif"]
8
+
9
+
10
+ class InvalidNIF(ValueError):
11
+ """O valor não é um NIF português válido."""
12
+
13
+
14
+ class NifType(str, Enum):
15
+ """Tipo de contribuinte, pelo prefixo do NIF."""
16
+
17
+ INDIVIDUAL = "pessoa singular"
18
+ NON_RESIDENT_INDIVIDUAL = "pessoa singular não residente"
19
+ COMPANY = "pessoa coletiva"
20
+ PUBLIC_ADMINISTRATION = "administração pública"
21
+ UNDIVIDED_INHERITANCE = "herança indivisa"
22
+ NON_RESIDENT_COMPANY = "pessoa coletiva não residente"
23
+ INVESTMENT_FUND = "fundo de investimento"
24
+ OFFICIAL_ASSIGNMENT = "atribuição oficiosa"
25
+ EXCEPTIONAL_REGIME = "regime excecional"
26
+ SOLE_TRADER = "empresário em nome individual"
27
+ CONDOMINIUM_OR_IRREGULAR = "condomínio ou sociedade irregular"
28
+ NON_RESIDENT_NO_ESTABLISHMENT = "não residente sem estabelecimento estável"
29
+ CIVIL_PARTNERSHIP = "sociedade civil sem personalidade jurídica"
30
+
31
+
32
+ # Prefixos de dois dígitos primeiro: "45" tem de ganhar a "4" (que não existe sozinho).
33
+ _PREFIXES: tuple[tuple[str, NifType], ...] = (
34
+ ("45", NifType.NON_RESIDENT_INDIVIDUAL),
35
+ ("70", NifType.UNDIVIDED_INHERITANCE),
36
+ ("71", NifType.NON_RESIDENT_COMPANY),
37
+ ("72", NifType.INVESTMENT_FUND),
38
+ ("74", NifType.UNDIVIDED_INHERITANCE),
39
+ ("75", NifType.UNDIVIDED_INHERITANCE),
40
+ ("77", NifType.OFFICIAL_ASSIGNMENT),
41
+ ("79", NifType.EXCEPTIONAL_REGIME),
42
+ ("90", NifType.CONDOMINIUM_OR_IRREGULAR),
43
+ ("91", NifType.CONDOMINIUM_OR_IRREGULAR),
44
+ ("98", NifType.NON_RESIDENT_NO_ESTABLISHMENT),
45
+ ("99", NifType.CIVIL_PARTNERSHIP),
46
+ ("1", NifType.INDIVIDUAL),
47
+ ("2", NifType.INDIVIDUAL),
48
+ ("3", NifType.INDIVIDUAL),
49
+ ("5", NifType.COMPANY),
50
+ ("6", NifType.PUBLIC_ADMINISTRATION),
51
+ ("8", NifType.SOLE_TRADER),
52
+ )
53
+
54
+
55
+ def normalize_nif(value: str | int) -> str:
56
+ """Tira espaços, pontos, hífenes e o prefixo ``PT``.
57
+
58
+ Não valida: ``normalize_nif(" PT 123 456 789 ")`` dá ``"123456789"``.
59
+ """
60
+ text = str(value).strip().upper()
61
+ if text.startswith("PT"):
62
+ text = text[2:]
63
+ return "".join(ch for ch in text if ch not in " .-")
64
+
65
+
66
+ def _check_digit(first_eight: str) -> int:
67
+ total = sum(int(d) * w for d, w in zip(first_eight, range(9, 1, -1), strict=True))
68
+ remainder = total % 11
69
+ return 0 if remainder < 2 else 11 - remainder
70
+
71
+
72
+ def nif_type(value: str | int) -> NifType:
73
+ """Devolve o tipo de contribuinte, ou lança :class:`InvalidNIF`."""
74
+ nif = normalize_nif(value)
75
+ if len(nif) != 9 or not nif.isdigit():
76
+ raise InvalidNIF(f"O NIF tem de ter 9 dígitos: {value!r}")
77
+ kind = next((t for prefix, t in _PREFIXES if nif.startswith(prefix)), None)
78
+ if kind is None:
79
+ raise InvalidNIF(f"O NIF começa por um prefixo que não existe: {value!r}")
80
+ if int(nif[8]) != _check_digit(nif[:8]):
81
+ raise InvalidNIF(f"O dígito de controlo do NIF não confere: {value!r}")
82
+ return kind
83
+
84
+
85
+ def is_valid_nif(value: str | int) -> bool:
86
+ """``True`` se o valor (depois de :func:`normalize_nif`) for um NIF válido."""
87
+ try:
88
+ nif_type(value)
89
+ except InvalidNIF:
90
+ return False
91
+ return True
File without changes