tiss-hash 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,177 @@
1
+ # =============================================================================
2
+ # .gitignore — TISS_ANS_hash
3
+ # =============================================================================
4
+ # Regra crítica de segurança: NUNCA commitar XML real com PII de pacientes.
5
+ # Padrão `real_*` bloqueado em qualquer lugar do repo.
6
+ # Vetores sintéticos vivem em conformance/inputs/syn_*.xml (permitidos).
7
+ # =============================================================================
8
+
9
+ # ----- LGPD: bloqueio de dados reais de pacientes -----
10
+ **/real_envio*.xml
11
+ **/real_*.xml
12
+ _private_*/
13
+ .private/
14
+ *.pii.xml
15
+
16
+ # ----- Python -----
17
+ __pycache__/
18
+ *.py[cod]
19
+ *$py.class
20
+ *.so
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ share/python-wheels/
35
+ *.egg-info/
36
+ .installed.cfg
37
+ *.egg
38
+ MANIFEST
39
+
40
+ # Excecao: o port Dart usa `lib/` como diretorio de CODIGO-FONTE (convencao
41
+ # pub.dev), nao como artefato de build Python. Re-inclui essa subarvore (a
42
+ # regra `lib/` acima vem do template Python e a excluiria por engano).
43
+ !langs/dart/lib/
44
+ !langs/dart/lib/**
45
+
46
+ # Virtualenvs
47
+ .venv/
48
+ venv/
49
+ env/
50
+ ENV/
51
+ .python-version
52
+
53
+ # pytest / coverage / mypy / ruff
54
+ .pytest_cache/
55
+ .coverage
56
+ .coverage.*
57
+ htmlcov/
58
+ .tox/
59
+ .nox/
60
+ coverage.xml
61
+ *.cover
62
+ .cache
63
+ .mypy_cache/
64
+ .ruff_cache/
65
+ .pyre/
66
+ .pytype/
67
+
68
+ # tox / hatch
69
+ .hatch/
70
+
71
+ # ----- Node.js -----
72
+ node_modules/
73
+ npm-debug.log*
74
+ yarn-debug.log*
75
+ yarn-error.log*
76
+ pnpm-debug.log*
77
+ .pnpm-store/
78
+ .npm/
79
+ package-lock.json.bak
80
+
81
+ # ----- Rust -----
82
+ target/
83
+ Cargo.lock
84
+ **/*.rs.bk
85
+
86
+ # ----- C / C++ -----
87
+ *.o
88
+ *.obj
89
+ *.a
90
+ *.lib
91
+ *.so
92
+ *.so.*
93
+ *.dylib
94
+ *.dll
95
+ *.exe
96
+ *.out
97
+ *.app
98
+ *.gcda
99
+ *.gcno
100
+ *.gcov
101
+ build/
102
+ build_*/
103
+ cmake-build-*/
104
+ CMakeCache.txt
105
+ CMakeFiles/
106
+ cmake_install.cmake
107
+ Makefile.local
108
+
109
+ # ----- PHP -----
110
+ vendor/
111
+ composer.lock
112
+ composer.phar
113
+ .phpunit.result.cache
114
+
115
+ # ----- Java / Kotlin -----
116
+ *.class
117
+ *.jar
118
+ *.war
119
+ *.ear
120
+ *.nar
121
+ target/
122
+ .gradle/
123
+ build/
124
+ .kotlin/
125
+ .idea/
126
+ *.iml
127
+
128
+ # ----- C# / .NET -----
129
+ bin/
130
+ obj/
131
+ *.user
132
+ *.suo
133
+ *.userprefs
134
+ .vs/
135
+ [Dd]ebug/
136
+ [Rr]elease/
137
+
138
+ # ----- Go -----
139
+ *.test
140
+ *.prof
141
+ go.sum.bak
142
+
143
+ # ----- WASM -----
144
+ *.wasm
145
+ pkg/
146
+
147
+ # ----- IDE / Editor -----
148
+ .idea/
149
+ .vscode/
150
+ *.swp
151
+ *.swo
152
+ *~
153
+ .DS_Store
154
+ Thumbs.db
155
+ .vim/
156
+ .helix/
157
+ .zed/
158
+
159
+ # ----- Logs / temp -----
160
+ *.log
161
+ *.tmp
162
+ *.bak
163
+ *.orig
164
+ *.rej
165
+
166
+ # ----- OS -----
167
+ .Spotlight-V100
168
+ .Trashes
169
+ ehthumbs.db
170
+ desktop.ini
171
+
172
+ # ----- Claude Code (manter settings.local fora) -----
173
+ .claude/settings.local.json
174
+ .claude/.cache/
175
+
176
+ # clang-tidy compile_commands scratch dirs (W4)
177
+ **/build_tidy/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Petrus Silva Costa <petrinhu@yahoo.com.br>
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,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: tiss-hash
3
+ Version: 0.1.0
4
+ Summary: Hash MD5 do epílogo TISS/ANS (Padrão TISS) — implementação portável zero-dep.
5
+ Project-URL: Homepage, https://github.com/petrinhu/TISS_ANS_hash
6
+ Project-URL: Repository, https://github.com/petrinhu/TISS_ANS_hash
7
+ Project-URL: Mirror (Codeberg), https://codeberg.org/petrinhu/TISS_ANS_hash
8
+ Project-URL: Issues, https://github.com/petrinhu/TISS_ANS_hash/issues
9
+ Project-URL: Changelog, https://github.com/petrinhu/TISS_ANS_hash/blob/main/CHANGELOG.md
10
+ Project-URL: Documentation, https://github.com/petrinhu/TISS_ANS_hash/blob/main/docs/USAGE.md
11
+ Author-email: Petrus <petrinhu@yahoo.com.br>
12
+ License: MIT License
13
+
14
+ Copyright (c) 2026 Petrus Silva Costa <petrinhu@yahoo.com.br>
15
+
16
+ Permission is hereby granted, free of charge, to any person obtaining a copy
17
+ of this software and associated documentation files (the "Software"), to deal
18
+ in the Software without restriction, including without limitation the rights
19
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20
+ copies of the Software, and to permit persons to whom the Software is
21
+ furnished to do so, subject to the following conditions:
22
+
23
+ The above copyright notice and this permission notice shall be included in all
24
+ copies or substantial portions of the Software.
25
+
26
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
+ SOFTWARE.
33
+ License-File: LICENSE
34
+ Keywords: ans,brasil,epilogo,hash,healthcare,md5,padrao-tiss,saude-suplementar,tiss,xml
35
+ Classifier: Development Status :: 3 - Alpha
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: Intended Audience :: Healthcare Industry
38
+ Classifier: License :: OSI Approved :: MIT License
39
+ Classifier: Natural Language :: Portuguese (Brazilian)
40
+ Classifier: Operating System :: OS Independent
41
+ Classifier: Programming Language :: Python :: 3
42
+ Classifier: Programming Language :: Python :: 3 :: Only
43
+ Classifier: Programming Language :: Python :: 3.10
44
+ Classifier: Programming Language :: Python :: 3.11
45
+ Classifier: Programming Language :: Python :: 3.12
46
+ Classifier: Programming Language :: Python :: 3.13
47
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
48
+ Classifier: Topic :: Text Processing :: Markup :: XML
49
+ Classifier: Typing :: Typed
50
+ Requires-Python: >=3.10
51
+ Requires-Dist: defusedxml>=0.7.1
52
+ Provides-Extra: dev
53
+ Requires-Dist: pytest-cov>=4.1; extra == 'dev'
54
+ Requires-Dist: pytest>=7.4; extra == 'dev'
55
+ Provides-Extra: lxml
56
+ Requires-Dist: lxml>=4.9; extra == 'lxml'
57
+ Description-Content-Type: text/markdown
58
+
59
+ # tiss-hash (Python)
60
+
61
+ Calcula o "hash" do trecho final de um documento TISS/ANS. Vamos por partes,
62
+ sem pressa:
63
+
64
+ - **XML** é um formato de arquivo de texto que organiza dados em etiquetas
65
+ (tags) aninhadas, parecido com as caixas dentro de caixas de uma pasta de
66
+ arquivos. O Padrão TISS é o formato XML que as operadoras de saúde e os
67
+ consultórios usam para trocar informações de atendimento no Brasil.
68
+ - **Hash** é uma "impressão digital" do conteúdo: uma sequência curta e fixa
69
+ de caracteres calculada a partir de um texto. Se uma única letra do texto
70
+ mudar, o hash muda completamente. Serve para conferir que dois lados estão
71
+ falando do mesmo documento.
72
+ - **MD5** é uma das receitas (algoritmos) que produzem esse hash. Ele sempre
73
+ devolve 32 caracteres hexadecimais (os dígitos `0-9` e as letras `a-f`).
74
+ - **Epílogo** é a parte final do documento TISS: a etiqueta `<ans:hash>`, onde
75
+ esse hash precisa ser gravado.
76
+
77
+ Em uma frase: você entrega os bytes de um XML TISS, esta biblioteca devolve os
78
+ 32 caracteres do hash que vão dentro de `<ans:hash>`. (Um **byte** é a menor
79
+ unidade de dado que o computador manipula; um arquivo de texto é uma fila de
80
+ bytes.)
81
+
82
+ Este é o port Python da biblioteca `lib_hash_ans`. ("Port" = a mesma
83
+ biblioteca reescrita em outra linguagem de programação.) Outras linguagens
84
+ (C, C++, Rust, PHP, Node.js, etc.) seguem o mesmo contrato e os mesmos
85
+ vetores de conformidade. Para entender o problema que esta lib resolve, leia
86
+ [`docs/USAGE.md`](../../docs/USAGE.md) (guia de uso) e
87
+ [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) (conceitos e visão geral).
88
+
89
+ ## Antes de começar: instalar o Python
90
+
91
+ Python é a linguagem de programação usada neste port. Se você nunca instalou:
92
+
93
+ - Baixe e instale pelo site oficial: <https://www.python.org/downloads/>
94
+ (precisa da versão 3.10 ou mais nova). No Windows, marque a caixa
95
+ "Add Python to PATH" durante a instalação.
96
+ - No Linux/macOS, o Python costuma já vir instalado. Confira com:
97
+
98
+ ```bash
99
+ python3 --version
100
+ ```
101
+
102
+ Se aparecer algo como `Python 3.12.x`, está pronto. O comando `pip` (gerenciador
103
+ que baixa bibliotecas Python) vem junto com o Python.
104
+
105
+ ## Quickstart
106
+
107
+ Uma **dependência** é uma biblioteca de terceiros que o seu código usa. O `pip`
108
+ baixa e instala dependências para você.
109
+
110
+ > Instalação via PyPI (o repositório oficial de pacotes Python) ainda não
111
+ > publicada. Por enquanto, instale a partir do checkout do repositório (isto é,
112
+ > da pasta que você baixou com `git clone`):
113
+
114
+ ```bash
115
+ pip install ./langs/python
116
+ ```
117
+
118
+ Quando publicada, bastará:
119
+
120
+ ```bash
121
+ pip install tiss-hash
122
+ ```
123
+
124
+ Uso:
125
+
126
+ ```python
127
+ from tiss_hash import hash_tiss, hash_tiss_file
128
+
129
+ # A partir de bytes
130
+ with open("lote_tiss_exemplo.xml", "rb") as fh:
131
+ digest = hash_tiss(fh.read())
132
+ print(digest) # hex MD5 de 32 chars, ex.: '3aa0c578c95cdb861a125f480a8a4de5'
133
+
134
+ # A partir de um caminho de arquivo
135
+ digest = hash_tiss_file("lote_tiss_exemplo.xml")
136
+ ```
137
+
138
+ Tratamento de erro:
139
+
140
+ ```python
141
+ from tiss_hash import InvalidTissXml, hash_tiss
142
+
143
+ try:
144
+ hash_tiss(b"<nao-eh-xml")
145
+ except InvalidTissXml as exc:
146
+ print(f"falhou ao parsear: {exc}")
147
+ ```
148
+
149
+ ## API
150
+
151
+ | Símbolo | Tipo | Descrição |
152
+ | --- | --- | --- |
153
+ | `hash_tiss(xml: bytes) -> str` | função | Hash MD5 (hex, 32 chars) a partir dos bytes do XML. |
154
+ | `hash_tiss_file(path: str \| os.PathLike) -> str` | função | Atalho que lê o arquivo e delega para `hash_tiss`. |
155
+ | `InvalidTissXml` | classe | Exceção (subclasse de `ValueError`) para XML malformado ou rejeitado por política de segurança. |
156
+ | `__version__` | str | Versão do pacote. |
157
+
158
+ ## Algoritmo
159
+
160
+ Resumo do que `hash_tiss` faz, em prosa. ("Parsear" um XML é ler o texto e
161
+ montar a árvore de etiquetas na memória; o **parser** é o componente que faz
162
+ essa leitura. **Encoding** é a tabela que traduz caracteres em bytes, por
163
+ exemplo UTF-8. **Namespace** é um prefixo que evita confusão entre etiquetas de
164
+ origens diferentes; aqui o namespace TISS identifica a etiqueta `<ans:hash>`.)
165
+
166
+ 1. Parseia o XML (com `defusedxml`, isto é, sem expansão de entidades e
167
+ sem DOCTYPE externo).
168
+ 2. Zera o conteúdo de `<ans:hash>` (namespace
169
+ `http://www.ans.gov.br/padroes/tiss/schemas`).
170
+ 3. Concatena o `.text` de cada elemento-folha (sem filhos) em ordem
171
+ documental.
172
+ 4. Calcula MD5 sobre os bytes **UTF-8** da string resultante.
173
+ 5. Devolve o `hexdigest()` minúsculo (32 caracteres).
174
+
175
+ Atenção: o encoding dos bytes alimentados ao MD5 é **UTF-8**, não
176
+ ISO-8859-1. O manual TISS afirma o contrário, mas o valor que bate com os
177
+ goldens reais é UTF-8.
178
+
179
+ Especificação canônica completa: `docs/SPEC.md` (na raiz do repositório).
180
+ Implementação de referência: `conformance/reference.py`.
181
+
182
+ ## Conformidade
183
+
184
+ "Conformidade" aqui significa: provar que este port produz exatamente o mesmo
185
+ hash que a implementação oficial, em todos os casos previstos. Um **vetor de
186
+ conformidade** é um par "arquivo de entrada -> hash esperado": rodamos a lib no
187
+ arquivo e conferimos se o resultado bate. Um vetor **positivo** deve produzir
188
+ um hash; um vetor **negativo** deve ser rejeitado (a lib precisa recusar o
189
+ arquivo, em vez de inventar um hash).
190
+
191
+ Esta lib passa os **20 vetores de conformidade** em
192
+ `conformance/vectors.json`, todos sintéticos (`source: derived`): 18
193
+ positivos e 2 negativos (que devem ser rejeitados). O conjunto público de
194
+ conformidade é 100% sintético, sem qualquer XML real de paciente. Os 18
195
+ positivos cobrem: mínimo, acentuação, campos vazios, CR/LF embutido,
196
+ múltiplas guias, entidades XML, entidades numéricas, CDATA, comentário,
197
+ atributo de folha, namespace alternativo, namespace default, documento sem
198
+ `<ans:hash>`, whitespace puro, zeros à esquerda, símbolos ISO-8859-1,
199
+ performance e BOM UTF-8. Os 2 negativos (`syn_multi_hash.xml` e
200
+ `syn_utf16.xml`) cobrem rejeição de múltiplos `<ans:hash>` e de UTF-16
201
+ (fora de escopo: encodings suportados são ISO-8859-1 e UTF-8). A lista
202
+ canônica vive em `conformance/vectors.json`.
203
+
204
+ Rodar os testes localmente, a partir da raiz do repositório:
205
+
206
+ ```bash
207
+ python -m pip install -e ./langs/python[dev]
208
+ pytest langs/python/tests/ -v
209
+ ```
210
+
211
+ Saída esperada: `24 passed`. Desses, 20 testes são os vetores de
212
+ conformidade (um por vetor, parametrizados: 18 positivos comparam o hash,
213
+ 2 negativos verificam a rejeição) e 4 são testes de API auxiliares
214
+ (`hash_tiss_file`, XML inválido, tipo de entrada errado e integridade do
215
+ manifesto).
216
+
217
+ ## Dependências
218
+
219
+ - Runtime: `defusedxml>=0.7.1` (pure-Python, sem deps próprias). Usado
220
+ no lugar de `xml.etree.ElementTree.parse` da stdlib para mitigar XXE
221
+ e billion-laughs em XMLs vindos de terceiros.
222
+ - Extra `lxml`: opcional, reservado para uma implementação alternativa
223
+ mais performática no futuro.
224
+ - Extra `dev`: `pytest`, `pytest-cov`.
225
+
226
+ ## Licença
227
+
228
+ [MIT](https://github.com/petrinhu/TISS_ANS_hash/blob/main/LICENSE)
229
+ Copyright (c) 2026 Petrus Silva Costa. Licença única do projeto, na raiz
230
+ do repositório.
231
+
232
+ ## Ver também
233
+
234
+ - Repositório (origin): https://github.com/petrinhu/TISS_ANS_hash
235
+ - Mirror: https://codeberg.org/petrinhu/TISS_ANS_hash
236
+ - [`docs/USAGE.md`](../../docs/USAGE.md): guia de uso, receitas e perguntas
237
+ frequentes (comece por aqui se você quer só usar a lib).
238
+ - [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md): conceitos e visão geral
239
+ de como tudo se encaixa.
240
+ - [`docs/SPEC.md`](../../docs/SPEC.md): especificação canônica do algoritmo
241
+ (a referência precisa, palavra por palavra).
242
+ - [`docs/PORTING_GUIDE.md`](../../docs/PORTING_GUIDE.md): guia para portar para
243
+ outras linguagens.
244
+ - [`conformance/reference.py`](../../conformance/reference.py): implementação de
245
+ referência (o "oráculo", isto é, a versão que define a resposta certa).
@@ -0,0 +1,187 @@
1
+ # tiss-hash (Python)
2
+
3
+ Calcula o "hash" do trecho final de um documento TISS/ANS. Vamos por partes,
4
+ sem pressa:
5
+
6
+ - **XML** é um formato de arquivo de texto que organiza dados em etiquetas
7
+ (tags) aninhadas, parecido com as caixas dentro de caixas de uma pasta de
8
+ arquivos. O Padrão TISS é o formato XML que as operadoras de saúde e os
9
+ consultórios usam para trocar informações de atendimento no Brasil.
10
+ - **Hash** é uma "impressão digital" do conteúdo: uma sequência curta e fixa
11
+ de caracteres calculada a partir de um texto. Se uma única letra do texto
12
+ mudar, o hash muda completamente. Serve para conferir que dois lados estão
13
+ falando do mesmo documento.
14
+ - **MD5** é uma das receitas (algoritmos) que produzem esse hash. Ele sempre
15
+ devolve 32 caracteres hexadecimais (os dígitos `0-9` e as letras `a-f`).
16
+ - **Epílogo** é a parte final do documento TISS: a etiqueta `<ans:hash>`, onde
17
+ esse hash precisa ser gravado.
18
+
19
+ Em uma frase: você entrega os bytes de um XML TISS, esta biblioteca devolve os
20
+ 32 caracteres do hash que vão dentro de `<ans:hash>`. (Um **byte** é a menor
21
+ unidade de dado que o computador manipula; um arquivo de texto é uma fila de
22
+ bytes.)
23
+
24
+ Este é o port Python da biblioteca `lib_hash_ans`. ("Port" = a mesma
25
+ biblioteca reescrita em outra linguagem de programação.) Outras linguagens
26
+ (C, C++, Rust, PHP, Node.js, etc.) seguem o mesmo contrato e os mesmos
27
+ vetores de conformidade. Para entender o problema que esta lib resolve, leia
28
+ [`docs/USAGE.md`](../../docs/USAGE.md) (guia de uso) e
29
+ [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) (conceitos e visão geral).
30
+
31
+ ## Antes de começar: instalar o Python
32
+
33
+ Python é a linguagem de programação usada neste port. Se você nunca instalou:
34
+
35
+ - Baixe e instale pelo site oficial: <https://www.python.org/downloads/>
36
+ (precisa da versão 3.10 ou mais nova). No Windows, marque a caixa
37
+ "Add Python to PATH" durante a instalação.
38
+ - No Linux/macOS, o Python costuma já vir instalado. Confira com:
39
+
40
+ ```bash
41
+ python3 --version
42
+ ```
43
+
44
+ Se aparecer algo como `Python 3.12.x`, está pronto. O comando `pip` (gerenciador
45
+ que baixa bibliotecas Python) vem junto com o Python.
46
+
47
+ ## Quickstart
48
+
49
+ Uma **dependência** é uma biblioteca de terceiros que o seu código usa. O `pip`
50
+ baixa e instala dependências para você.
51
+
52
+ > Instalação via PyPI (o repositório oficial de pacotes Python) ainda não
53
+ > publicada. Por enquanto, instale a partir do checkout do repositório (isto é,
54
+ > da pasta que você baixou com `git clone`):
55
+
56
+ ```bash
57
+ pip install ./langs/python
58
+ ```
59
+
60
+ Quando publicada, bastará:
61
+
62
+ ```bash
63
+ pip install tiss-hash
64
+ ```
65
+
66
+ Uso:
67
+
68
+ ```python
69
+ from tiss_hash import hash_tiss, hash_tiss_file
70
+
71
+ # A partir de bytes
72
+ with open("lote_tiss_exemplo.xml", "rb") as fh:
73
+ digest = hash_tiss(fh.read())
74
+ print(digest) # hex MD5 de 32 chars, ex.: '3aa0c578c95cdb861a125f480a8a4de5'
75
+
76
+ # A partir de um caminho de arquivo
77
+ digest = hash_tiss_file("lote_tiss_exemplo.xml")
78
+ ```
79
+
80
+ Tratamento de erro:
81
+
82
+ ```python
83
+ from tiss_hash import InvalidTissXml, hash_tiss
84
+
85
+ try:
86
+ hash_tiss(b"<nao-eh-xml")
87
+ except InvalidTissXml as exc:
88
+ print(f"falhou ao parsear: {exc}")
89
+ ```
90
+
91
+ ## API
92
+
93
+ | Símbolo | Tipo | Descrição |
94
+ | --- | --- | --- |
95
+ | `hash_tiss(xml: bytes) -> str` | função | Hash MD5 (hex, 32 chars) a partir dos bytes do XML. |
96
+ | `hash_tiss_file(path: str \| os.PathLike) -> str` | função | Atalho que lê o arquivo e delega para `hash_tiss`. |
97
+ | `InvalidTissXml` | classe | Exceção (subclasse de `ValueError`) para XML malformado ou rejeitado por política de segurança. |
98
+ | `__version__` | str | Versão do pacote. |
99
+
100
+ ## Algoritmo
101
+
102
+ Resumo do que `hash_tiss` faz, em prosa. ("Parsear" um XML é ler o texto e
103
+ montar a árvore de etiquetas na memória; o **parser** é o componente que faz
104
+ essa leitura. **Encoding** é a tabela que traduz caracteres em bytes, por
105
+ exemplo UTF-8. **Namespace** é um prefixo que evita confusão entre etiquetas de
106
+ origens diferentes; aqui o namespace TISS identifica a etiqueta `<ans:hash>`.)
107
+
108
+ 1. Parseia o XML (com `defusedxml`, isto é, sem expansão de entidades e
109
+ sem DOCTYPE externo).
110
+ 2. Zera o conteúdo de `<ans:hash>` (namespace
111
+ `http://www.ans.gov.br/padroes/tiss/schemas`).
112
+ 3. Concatena o `.text` de cada elemento-folha (sem filhos) em ordem
113
+ documental.
114
+ 4. Calcula MD5 sobre os bytes **UTF-8** da string resultante.
115
+ 5. Devolve o `hexdigest()` minúsculo (32 caracteres).
116
+
117
+ Atenção: o encoding dos bytes alimentados ao MD5 é **UTF-8**, não
118
+ ISO-8859-1. O manual TISS afirma o contrário, mas o valor que bate com os
119
+ goldens reais é UTF-8.
120
+
121
+ Especificação canônica completa: `docs/SPEC.md` (na raiz do repositório).
122
+ Implementação de referência: `conformance/reference.py`.
123
+
124
+ ## Conformidade
125
+
126
+ "Conformidade" aqui significa: provar que este port produz exatamente o mesmo
127
+ hash que a implementação oficial, em todos os casos previstos. Um **vetor de
128
+ conformidade** é um par "arquivo de entrada -> hash esperado": rodamos a lib no
129
+ arquivo e conferimos se o resultado bate. Um vetor **positivo** deve produzir
130
+ um hash; um vetor **negativo** deve ser rejeitado (a lib precisa recusar o
131
+ arquivo, em vez de inventar um hash).
132
+
133
+ Esta lib passa os **20 vetores de conformidade** em
134
+ `conformance/vectors.json`, todos sintéticos (`source: derived`): 18
135
+ positivos e 2 negativos (que devem ser rejeitados). O conjunto público de
136
+ conformidade é 100% sintético, sem qualquer XML real de paciente. Os 18
137
+ positivos cobrem: mínimo, acentuação, campos vazios, CR/LF embutido,
138
+ múltiplas guias, entidades XML, entidades numéricas, CDATA, comentário,
139
+ atributo de folha, namespace alternativo, namespace default, documento sem
140
+ `<ans:hash>`, whitespace puro, zeros à esquerda, símbolos ISO-8859-1,
141
+ performance e BOM UTF-8. Os 2 negativos (`syn_multi_hash.xml` e
142
+ `syn_utf16.xml`) cobrem rejeição de múltiplos `<ans:hash>` e de UTF-16
143
+ (fora de escopo: encodings suportados são ISO-8859-1 e UTF-8). A lista
144
+ canônica vive em `conformance/vectors.json`.
145
+
146
+ Rodar os testes localmente, a partir da raiz do repositório:
147
+
148
+ ```bash
149
+ python -m pip install -e ./langs/python[dev]
150
+ pytest langs/python/tests/ -v
151
+ ```
152
+
153
+ Saída esperada: `24 passed`. Desses, 20 testes são os vetores de
154
+ conformidade (um por vetor, parametrizados: 18 positivos comparam o hash,
155
+ 2 negativos verificam a rejeição) e 4 são testes de API auxiliares
156
+ (`hash_tiss_file`, XML inválido, tipo de entrada errado e integridade do
157
+ manifesto).
158
+
159
+ ## Dependências
160
+
161
+ - Runtime: `defusedxml>=0.7.1` (pure-Python, sem deps próprias). Usado
162
+ no lugar de `xml.etree.ElementTree.parse` da stdlib para mitigar XXE
163
+ e billion-laughs em XMLs vindos de terceiros.
164
+ - Extra `lxml`: opcional, reservado para uma implementação alternativa
165
+ mais performática no futuro.
166
+ - Extra `dev`: `pytest`, `pytest-cov`.
167
+
168
+ ## Licença
169
+
170
+ [MIT](https://github.com/petrinhu/TISS_ANS_hash/blob/main/LICENSE)
171
+ Copyright (c) 2026 Petrus Silva Costa. Licença única do projeto, na raiz
172
+ do repositório.
173
+
174
+ ## Ver também
175
+
176
+ - Repositório (origin): https://github.com/petrinhu/TISS_ANS_hash
177
+ - Mirror: https://codeberg.org/petrinhu/TISS_ANS_hash
178
+ - [`docs/USAGE.md`](../../docs/USAGE.md): guia de uso, receitas e perguntas
179
+ frequentes (comece por aqui se você quer só usar a lib).
180
+ - [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md): conceitos e visão geral
181
+ de como tudo se encaixa.
182
+ - [`docs/SPEC.md`](../../docs/SPEC.md): especificação canônica do algoritmo
183
+ (a referência precisa, palavra por palavra).
184
+ - [`docs/PORTING_GUIDE.md`](../../docs/PORTING_GUIDE.md): guia para portar para
185
+ outras linguagens.
186
+ - [`conformance/reference.py`](../../conformance/reference.py): implementação de
187
+ referência (o "oráculo", isto é, a versão que define a resposta certa).
@@ -0,0 +1,88 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tiss-hash"
7
+ version = "0.1.0"
8
+ description = "Hash MD5 do epílogo TISS/ANS (Padrão TISS) — implementação portável zero-dep."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [
13
+ { name = "Petrus", email = "petrinhu@yahoo.com.br" },
14
+ ]
15
+ keywords = ["tiss", "ans", "hash", "md5", "xml", "epilogo", "padrao-tiss", "saude-suplementar", "brasil", "healthcare"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Healthcare Industry",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Natural Language :: Portuguese (Brazilian)",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Topic :: Text Processing :: Markup :: XML",
31
+ "Typing :: Typed",
32
+ ]
33
+
34
+ # Dependência runtime obrigatória: defusedxml.
35
+ # Justificativa: parsers XML da stdlib são vulneráveis a XXE e
36
+ # billion-laughs. defusedxml é pure-Python, sem deps próprias, mantido
37
+ # pelo PSF; o custo de adicionar é mínimo e o ganho de segurança é
38
+ # mandatório para uma lib que processa XML potencialmente externo.
39
+ dependencies = [
40
+ "defusedxml>=0.7.1",
41
+ ]
42
+
43
+ [project.optional-dependencies]
44
+ # Extra opcional para parsing mais rápido em arquivos grandes (futuro).
45
+ # Atualmente o core usa stdlib; este extra está reservado para uma
46
+ # implementação alternativa baseada em lxml caso surja necessidade.
47
+ lxml = ["lxml>=4.9"]
48
+ # Conjunto para desenvolvimento/teste.
49
+ dev = [
50
+ "pytest>=7.4",
51
+ "pytest-cov>=4.1",
52
+ ]
53
+
54
+ [project.urls]
55
+ Homepage = "https://github.com/petrinhu/TISS_ANS_hash"
56
+ Repository = "https://github.com/petrinhu/TISS_ANS_hash"
57
+ "Mirror (Codeberg)" = "https://codeberg.org/petrinhu/TISS_ANS_hash"
58
+ Issues = "https://github.com/petrinhu/TISS_ANS_hash/issues"
59
+ Changelog = "https://github.com/petrinhu/TISS_ANS_hash/blob/main/CHANGELOG.md"
60
+ Documentation = "https://github.com/petrinhu/TISS_ANS_hash/blob/main/docs/USAGE.md"
61
+
62
+ [tool.hatch.build.targets.wheel]
63
+ packages = ["src/tiss_hash"]
64
+
65
+ [tool.hatch.build.targets.sdist]
66
+ include = [
67
+ "src/tiss_hash",
68
+ "README.md",
69
+ "LICENSE",
70
+ "pyproject.toml",
71
+ "tests",
72
+ ]
73
+
74
+ [tool.pytest.ini_options]
75
+ testpaths = ["tests"]
76
+ addopts = "-ra"
77
+
78
+ [tool.ruff]
79
+ line-length = 100
80
+ target-version = "py310"
81
+ src = ["src", "tests"]
82
+
83
+ [tool.ruff.lint]
84
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
85
+ ignore = []
86
+
87
+ [tool.ruff.lint.per-file-ignores]
88
+ "tests/**" = ["B011"] # asserts em testes
@@ -0,0 +1,43 @@
1
+ """tiss_hash — geração do hash MD5 do epílogo TISS/ANS.
2
+
3
+ API pública:
4
+ - :func:`hash_tiss` — calcula o hash a partir dos bytes do XML.
5
+ - :func:`hash_tiss_file` — atalho para arquivos em disco.
6
+ - :class:`InvalidTissXml` — exceção para XML malformado.
7
+ - :data:`__version__` — versão do pacote.
8
+
9
+ Spec do algoritmo: ver ``docs/SPEC.md`` no repositório e a implementação
10
+ de referência em ``conformance/reference.py``.
11
+
12
+ Exemplo:
13
+ >>> from tiss_hash import hash_tiss
14
+ >>> with open("lote.xml", "rb") as fh:
15
+ ... hash_tiss(fh.read()) # valor ilustrativo (vetor syn_minimal)
16
+ '3aa0c578c95cdb861a125f480a8a4de5'
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from ._core import InvalidTissXml, hash_tiss, hash_tiss_file
22
+
23
+ # Versão é resolvida via metadata do pacote instalado (PEP 621 / pyproject).
24
+ # Fallback "0.0.0+unknown" cobre execução direta a partir do source tree
25
+ # (sem ``pip install``), o que é comum em desenvolvimento.
26
+ try:
27
+ from importlib.metadata import PackageNotFoundError
28
+ from importlib.metadata import version as _pkg_version
29
+
30
+ try:
31
+ __version__: str = _pkg_version("tiss-hash")
32
+ except PackageNotFoundError: # pragma: no cover - execução in-tree
33
+ __version__ = "0.0.0+unknown"
34
+ except ImportError: # pragma: no cover - Python <3.8, não suportado
35
+ __version__ = "0.0.0+unknown"
36
+
37
+
38
+ __all__ = [
39
+ "InvalidTissXml",
40
+ "__version__",
41
+ "hash_tiss",
42
+ "hash_tiss_file",
43
+ ]
@@ -0,0 +1,166 @@
1
+ """Implementação portável do hash MD5 do epílogo TISS/ANS.
2
+
3
+ Módulo interno; a API pública é exposta por ``tiss_hash`` (ver ``__init__``).
4
+
5
+ Algoritmo canônico (validado contra goldens reais em ``conformance/``):
6
+
7
+ 1. Parse do XML.
8
+ 2. Zerar o conteúdo de ``<ans:hash>`` (não entra no cálculo).
9
+ 3. Concatenar o ``.text`` de cada elemento-FOLHA (sem filhos), em ordem
10
+ de documento. Sem nomes de tag, sem atributos. TISS não tem conteúdo
11
+ misto, portanto folha = valor.
12
+ 4. Calcular MD5 sobre os bytes **UTF-8** da string concatenada.
13
+ 5. ``hexdigest()`` minúsculo (32 chars).
14
+
15
+ Atenção: o encoding dos bytes passados ao MD5 é **UTF-8**, NÃO ISO-8859-1
16
+ (apesar do que diz a prosa do manual TISS). Os arquivos são lidos respeitando
17
+ sua declaração XML (geralmente ISO-8859-1), mas os valores extraídos são
18
+ re-encodados em UTF-8 antes do MD5.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import io
25
+ import os
26
+ from xml.etree.ElementTree import ParseError, TreeBuilder
27
+
28
+ from defusedxml.common import DefusedXmlException
29
+
30
+ # defusedxml é usado em vez de xml.etree.ElementTree.parse porque os
31
+ # parsers da stdlib são vulneráveis a XXE (XML External Entity) e
32
+ # "billion laughs" / quadratic blowup. Como esta lib pode receber XMLs
33
+ # vindos de operadoras / parceiros externos, parsing seguro é mandatório.
34
+ # defusedxml.ElementTree é drop-in compatível com xml.etree e desabilita
35
+ # por padrão DOCTYPE, entidades externas e expansion de entidades.
36
+ from defusedxml.ElementTree import DefusedXMLParser
37
+ from defusedxml.ElementTree import parse as _safe_parse
38
+
39
+ __all__ = ["InvalidTissXml", "hash_tiss", "hash_tiss_file"]
40
+
41
+ # Namespace oficial do padrão TISS/ANS.
42
+ _TISS_NS = "http://www.ans.gov.br/padroes/tiss/schemas"
43
+
44
+ # Tag do elemento de hash já expandida em notação Clark (``{uri}localname``),
45
+ # que é a forma como ``xml.etree.ElementTree`` representa elementos namespaced.
46
+ _HASH_TAG = f"{{{_TISS_NS}}}hash"
47
+
48
+
49
+ class InvalidTissXml(ValueError):
50
+ """XML de entrada inválido ou não parseável.
51
+
52
+ Subclasse de :class:`ValueError` para preservar idiomatismo Python ao
53
+ mesmo tempo em que permite tratamento específico.
54
+ """
55
+
56
+
57
+ def hash_tiss(xml: bytes) -> str:
58
+ """Calcula o hash MD5 do epílogo TISS/ANS a partir dos bytes do XML.
59
+
60
+ Recebe os bytes brutos do arquivo (a declaração XML interna define o
61
+ encoding original, geralmente ISO-8859-1) e devolve o hash em hex
62
+ minúsculo, exatamente como gravado no elemento ``<ans:hash>``.
63
+
64
+ Args:
65
+ xml: bytes do documento XML completo.
66
+
67
+ Returns:
68
+ Hash MD5 em hexadecimal minúsculo, 32 caracteres.
69
+
70
+ Raises:
71
+ InvalidTissXml: se o XML estiver malformado, contiver construções
72
+ inseguras (DOCTYPE, entidades externas, billion-laughs) ou não
73
+ puder ser parseado.
74
+ TypeError: se ``xml`` não for ``bytes``/``bytearray``/``memoryview``.
75
+ """
76
+ if not isinstance(xml, (bytes, bytearray, memoryview)):
77
+ raise TypeError(
78
+ f"hash_tiss espera bytes-like, recebeu {type(xml).__name__}"
79
+ )
80
+
81
+ raw = bytes(xml)
82
+
83
+ # Rejeição por BOM de encoding fora de escopo. O escopo suportado é
84
+ # ISO-8859-1 + UTF-8 (ver SPEC.md §7 / AMBIGUITY_NOTES.md §11b). UTF-16
85
+ # e UTF-32 são proibidos pelo padrão TISS. Detectamos pelo BOM nos bytes
86
+ # de entrada e rejeitamos antes de parsear (o parser XML aceitaria
87
+ # UTF-16/32 silenciosamente, produzindo um hash inválido).
88
+ #
89
+ # ORDEM IMPORTA: o BOM UTF-32 LE (FF FE 00 00) tem como prefixo o BOM
90
+ # UTF-16 LE (FF FE); por isso checamos UTF-32 (4 bytes) ANTES de UTF-16
91
+ # (2 bytes), senão um arquivo UTF-32 seria classificado errado.
92
+ if raw[:4] in (b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff"):
93
+ raise InvalidTissXml(
94
+ "encoding UTF-32 fora de escopo (BOM detectado): "
95
+ "TISS suporta apenas ISO-8859-1 e UTF-8"
96
+ )
97
+ if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
98
+ raise InvalidTissXml(
99
+ "encoding UTF-16 fora de escopo (BOM detectado): "
100
+ "TISS suporta apenas ISO-8859-1 e UTF-8"
101
+ )
102
+
103
+ # Parser configurado com ``insert_comments=True`` no TreeBuilder para
104
+ # reproduzir o comportamento da implementação de referência (lxml),
105
+ # onde nós-comentário são filhos do elemento pai, têm ``len(el) == 0``
106
+ # e portanto contribuem seu ``.text`` para o concat de folhas.
107
+ # Ver ``conformance/vectors.json`` vetor ``syn_comentario.xml`` e a
108
+ # nota ``conformance/AMBIGUITY_NOTES.md``.
109
+ try:
110
+ parser = DefusedXMLParser(
111
+ target=TreeBuilder(insert_comments=True),
112
+ )
113
+ tree = _safe_parse(io.BytesIO(raw), parser=parser)
114
+ except DefusedXmlException as exc:
115
+ # XXE / DTD externo / entidade proibida / billion-laughs.
116
+ raise InvalidTissXml(
117
+ f"XML rejeitado por política de segurança: {exc}"
118
+ ) from exc
119
+ except ParseError as exc:
120
+ raise InvalidTissXml(f"XML inválido: {exc}") from exc
121
+
122
+ root = tree.getroot()
123
+
124
+ # Localiza TODOS os elementos <ans:hash> do namespace TISS, casando por
125
+ # URI + nome local (notação Clark ``{uri}hash``) — nunca pelo prefixo
126
+ # literal. Isso cobre tanto o caso ``ans:hash`` quanto o namespace TISS
127
+ # declarado como default (``xmlns=...``), pois ElementTree expande ambos
128
+ # para a mesma tag Clark.
129
+ #
130
+ # O padrão TISS define exatamente um <ans:hash> por mensagem. Mais de um
131
+ # é documento inválido (A-COV2): rejeitamos em vez de adivinhar qual zerar.
132
+ # ``root.iter(tag)`` percorre a árvore inteira incluindo a própria raiz.
133
+ hash_els = list(root.iter(_HASH_TAG))
134
+ if len(hash_els) > 1:
135
+ raise InvalidTissXml(
136
+ f"documento contém {len(hash_els)} elementos <ans:hash>; "
137
+ "o padrão TISS define exatamente 1"
138
+ )
139
+
140
+ # Zera o conteúdo do elemento <ans:hash> antes de calcular (se existir;
141
+ # documento sem <ans:hash> é válido e concatena tudo).
142
+ if hash_els:
143
+ hash_els[0].text = ""
144
+
145
+ # Concatena .text de cada elemento-folha (len(el) == 0) em ordem documental.
146
+ partes = [(el.text or "") for el in root.iter() if len(el) == 0]
147
+ payload = "".join(partes)
148
+
149
+ return hashlib.md5(payload.encode("utf-8")).hexdigest()
150
+
151
+
152
+ def hash_tiss_file(path: str | os.PathLike[str]) -> str:
153
+ """Atalho conveniente que lê o arquivo do disco e calcula o hash.
154
+
155
+ Args:
156
+ path: caminho do arquivo XML (str ou path-like).
157
+
158
+ Returns:
159
+ Hash MD5 em hexadecimal minúsculo, 32 caracteres.
160
+
161
+ Raises:
162
+ InvalidTissXml: se o XML estiver malformado.
163
+ OSError: se o arquivo não puder ser aberto/lido.
164
+ """
165
+ with open(os.fspath(path), "rb") as fh:
166
+ return hash_tiss(fh.read())
@@ -0,0 +1,40 @@
1
+ """Fixtures compartilhadas pela suíte de conformidade da lib ``tiss_hash``.
2
+
3
+ Resolve o diretório ``conformance/`` na raiz do repositório (independente
4
+ de onde o pytest seja invocado) e expõe-o como fixture, junto com o
5
+ manifesto ``vectors.json`` carregado.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import pytest
15
+
16
+ # tests/conftest.py -> tests/ -> python/ -> langs/ -> repo_root
17
+ _REPO_ROOT = Path(__file__).resolve().parents[3]
18
+ _CONFORMANCE_DIR = _REPO_ROOT / "conformance"
19
+ _VECTORS_PATH = _CONFORMANCE_DIR / "vectors.json"
20
+
21
+
22
+ @pytest.fixture(scope="session")
23
+ def conformance_dir() -> Path:
24
+ """Caminho absoluto do diretório ``conformance/`` na raiz do repo."""
25
+ if not _CONFORMANCE_DIR.is_dir():
26
+ pytest.fail(
27
+ f"diretório conformance/ não encontrado em {_CONFORMANCE_DIR}; "
28
+ "esta suíte deve rodar dentro do checkout completo do repo "
29
+ "lib_hash_ans, não a partir do wheel instalado."
30
+ )
31
+ return _CONFORMANCE_DIR
32
+
33
+
34
+ @pytest.fixture(scope="session")
35
+ def vectors_manifest(conformance_dir: Path) -> dict[str, Any]:
36
+ """Conteúdo parseado de ``conformance/vectors.json``."""
37
+ if not _VECTORS_PATH.is_file():
38
+ pytest.fail(f"vectors.json ausente: {_VECTORS_PATH}")
39
+ with _VECTORS_PATH.open("r", encoding="utf-8") as fh:
40
+ return json.load(fh)
@@ -0,0 +1,123 @@
1
+ """Suíte de conformidade: a lib ``tiss_hash`` deve bater os 8 vetores
2
+ canônicos definidos em ``conformance/vectors.json``.
3
+
4
+ Cada vetor vira um teste parametrizado nomeado com o ``id`` do vetor,
5
+ facilitando ler a saída do ``pytest -v``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import pytest
15
+
16
+ from tiss_hash import InvalidTissXml, hash_tiss, hash_tiss_file
17
+
18
+ # Carregamento do manifesto em import-time para alimentar @parametrize.
19
+ # Replicamos a lógica do conftest aqui porque parametrize precisa dos
20
+ # valores antes da fase de fixtures.
21
+ _REPO_ROOT = Path(__file__).resolve().parents[3]
22
+ _CONFORMANCE_DIR = _REPO_ROOT / "conformance"
23
+ _VECTORS_PATH = _CONFORMANCE_DIR / "vectors.json"
24
+
25
+ if _VECTORS_PATH.is_file():
26
+ with _VECTORS_PATH.open("r", encoding="utf-8") as _fh:
27
+ _MANIFEST: dict[str, Any] = json.load(_fh)
28
+ _VECTORS: list[dict[str, Any]] = _MANIFEST.get("vectors", [])
29
+ else:
30
+ _VECTORS = []
31
+
32
+
33
+ @pytest.mark.skipif(not _VECTORS, reason="vectors.json ausente ou vazio")
34
+ @pytest.mark.parametrize(
35
+ "vector",
36
+ _VECTORS,
37
+ ids=[v["id"] for v in _VECTORS],
38
+ )
39
+ def test_vector_matches_expected(
40
+ vector: dict[str, Any],
41
+ conformance_dir: Path,
42
+ ) -> None:
43
+ """Cada vetor: lê o XML do disco e verifica conforme o tipo.
44
+
45
+ O campo ``expect`` do manifesto define o tipo do vetor:
46
+ - ausente ou ``"hash"``: vetor POSITIVO — calcula e compara com
47
+ ``expected_md5``.
48
+ - ``"error"``: vetor NEGATIVO — ``hash_tiss`` DEVE rejeitar o input
49
+ lançando ``InvalidTissXml`` (nunca retornar um hash).
50
+ """
51
+ input_path = conformance_dir / vector["input"]
52
+ assert input_path.is_file(), f"input ausente: {input_path}"
53
+
54
+ raw = input_path.read_bytes()
55
+ expect = vector.get("expect", "hash")
56
+
57
+ if expect == "error":
58
+ assert vector["expected_md5"] is None, (
59
+ f"vetor negativo {vector['id']} não deveria ter expected_md5"
60
+ )
61
+ with pytest.raises(InvalidTissXml):
62
+ hash_tiss(raw)
63
+ return
64
+
65
+ got = hash_tiss(raw)
66
+ assert got == vector["expected_md5"], (
67
+ f"hash divergente para {vector['id']}: "
68
+ f"obtido {got!r}, esperado {vector['expected_md5']!r}"
69
+ )
70
+
71
+
72
+ @pytest.mark.skipif(not _VECTORS, reason="vectors.json ausente ou vazio")
73
+ def test_manifest_contains_core_vectors() -> None:
74
+ """Garante que o manifesto contém os vetores núcleo originais.
75
+
76
+ O manifesto cresce ao longo do tempo (vetores sintéticos novos),
77
+ mas estes 8 são considerados o conjunto mínimo de conformidade.
78
+ """
79
+ # Vetores reais (real_envio*.xml) NÃO entram no manifesto público
80
+ # porque contêm PII de pacientes — vivem em diretório privado fora do
81
+ # repo (ver build_fixture.py: TISS_PRIVATE_XMLS). A validação contra
82
+ # eles roda apenas em ambiente privado do mantenedor.
83
+ nucleo = {
84
+ "syn_minimal.xml",
85
+ "syn_acento.xml",
86
+ "syn_empty.xml",
87
+ "syn_crlf_value.xml",
88
+ "syn_multi_guia.xml",
89
+ }
90
+ presentes = {v["id"] for v in _VECTORS}
91
+ faltando = nucleo - presentes
92
+ assert not faltando, (
93
+ f"vetores núcleo ausentes do manifesto: {sorted(faltando)}"
94
+ )
95
+ assert len(_VECTORS) >= 5, (
96
+ f"esperados ao menos 5 vetores núcleo no manifesto, encontrados {len(_VECTORS)}"
97
+ )
98
+
99
+
100
+ @pytest.mark.skipif(not _VECTORS, reason="vectors.json ausente ou vazio")
101
+ def test_hash_tiss_file_matches_hash_tiss(conformance_dir: Path) -> None:
102
+ """``hash_tiss_file`` deve produzir o mesmo resultado de ``hash_tiss``."""
103
+ vec = _VECTORS[0]
104
+ input_path = conformance_dir / vec["input"]
105
+ assert hash_tiss_file(str(input_path)) == hash_tiss(input_path.read_bytes())
106
+ # Também aceita PathLike.
107
+ assert hash_tiss_file(input_path) == vec["expected_md5"]
108
+
109
+
110
+ def test_invalid_xml_raises_invalid_tiss_xml() -> None:
111
+ """XML malformado deve disparar ``InvalidTissXml`` (subclasse de ValueError)."""
112
+ with pytest.raises(InvalidTissXml):
113
+ hash_tiss(b"<isto-nao-fecha>")
114
+ # Compatibilidade idiomática: também é capturável como ValueError.
115
+ with pytest.raises(ValueError):
116
+ hash_tiss(b"<isto-nao-fecha>")
117
+
118
+
119
+ def test_non_bytes_input_raises_type_error() -> None:
120
+ """Input com tipo errado deve falhar cedo com TypeError, não confundir
121
+ com erro de parsing."""
122
+ with pytest.raises(TypeError):
123
+ hash_tiss("string-em-vez-de-bytes") # type: ignore[arg-type]