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.
- tiss_hash-0.1.0/.gitignore +177 -0
- tiss_hash-0.1.0/LICENSE +21 -0
- tiss_hash-0.1.0/PKG-INFO +245 -0
- tiss_hash-0.1.0/README.md +187 -0
- tiss_hash-0.1.0/pyproject.toml +88 -0
- tiss_hash-0.1.0/src/tiss_hash/__init__.py +43 -0
- tiss_hash-0.1.0/src/tiss_hash/_core.py +166 -0
- tiss_hash-0.1.0/tests/conftest.py +40 -0
- tiss_hash-0.1.0/tests/test_conformance.py +123 -0
|
@@ -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/
|
tiss_hash-0.1.0/LICENSE
ADDED
|
@@ -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.
|
tiss_hash-0.1.0/PKG-INFO
ADDED
|
@@ -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]
|