almztech-sdk 0.2.0b1__py3-none-any.whl
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.
- almztech/__init__.py +72 -0
- almztech/erros.py +72 -0
- almztech/filtros.py +81 -0
- almztech/log.py +41 -0
- almztech/modelos.py +121 -0
- almztech/recursos.py +261 -0
- almztech/transporte.py +186 -0
- almztech/webhook.py +53 -0
- almztech_sdk-0.2.0b1.dist-info/METADATA +116 -0
- almztech_sdk-0.2.0b1.dist-info/RECORD +13 -0
- almztech_sdk-0.2.0b1.dist-info/WHEEL +5 -0
- almztech_sdk-0.2.0b1.dist-info/licenses/LICENSE +21 -0
- almztech_sdk-0.2.0b1.dist-info/top_level.txt +1 -0
almztech/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""SDK oficial Python da Almztech (DESIGN.md).
|
|
2
|
+
|
|
3
|
+
Exemplo::
|
|
4
|
+
|
|
5
|
+
from almztech import AlmztechClient
|
|
6
|
+
|
|
7
|
+
client = AlmztechClient(os.environ["ALMZTECH_API_KEY"])
|
|
8
|
+
protocolo = client.notas_fiscais.emitir(lote)
|
|
9
|
+
resultado = client.notas_fiscais.aguardar_conclusao(protocolo["protocolo"])
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from . import filtros
|
|
17
|
+
from .erros import (
|
|
18
|
+
AlmztechErro,
|
|
19
|
+
AlmztechErroApi,
|
|
20
|
+
AlmztechErroAutenticacao,
|
|
21
|
+
AlmztechErroAutorizacao,
|
|
22
|
+
AlmztechErroLimiteDeRequisicoes,
|
|
23
|
+
AlmztechErroProtocoloRejeitado,
|
|
24
|
+
AlmztechErroRede,
|
|
25
|
+
AlmztechErroServidor,
|
|
26
|
+
AlmztechErroTempoDePollingEsgotado,
|
|
27
|
+
AlmztechErroValidacao,
|
|
28
|
+
AlmztechErroWebhook,
|
|
29
|
+
)
|
|
30
|
+
from .log import mascarar_api_key, mascarar_corpo_json
|
|
31
|
+
from .recursos import Emitentes, Municipios, NotasFiscais, Webhooks
|
|
32
|
+
from .transporte import VERSAO_SDK, OpcoesDeRetry, OpcoesDoClient, Transporte
|
|
33
|
+
from .webhook import EventoWebhook, ler_webhook
|
|
34
|
+
|
|
35
|
+
__version__ = VERSAO_SDK
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AlmztechClient:
|
|
39
|
+
"""Client raiz. Crie um por aplicação e reaproveite.
|
|
40
|
+
|
|
41
|
+
A api key está em "Primeiros passos" no Portal; nunca guarde login e senha no seu sistema.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, api_key: str, opcoes: Optional[OpcoesDoClient] = None):
|
|
45
|
+
transporte = Transporte(api_key, opcoes)
|
|
46
|
+
self.notas_fiscais = NotasFiscais(transporte)
|
|
47
|
+
self.emitentes = Emitentes(transporte)
|
|
48
|
+
self.webhooks = Webhooks(transporte)
|
|
49
|
+
self.municipios = Municipios(transporte)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"AlmztechClient",
|
|
54
|
+
"OpcoesDoClient",
|
|
55
|
+
"OpcoesDeRetry",
|
|
56
|
+
"filtros",
|
|
57
|
+
"ler_webhook",
|
|
58
|
+
"EventoWebhook",
|
|
59
|
+
"mascarar_api_key",
|
|
60
|
+
"mascarar_corpo_json",
|
|
61
|
+
"AlmztechErro",
|
|
62
|
+
"AlmztechErroApi",
|
|
63
|
+
"AlmztechErroValidacao",
|
|
64
|
+
"AlmztechErroAutenticacao",
|
|
65
|
+
"AlmztechErroAutorizacao",
|
|
66
|
+
"AlmztechErroLimiteDeRequisicoes",
|
|
67
|
+
"AlmztechErroServidor",
|
|
68
|
+
"AlmztechErroRede",
|
|
69
|
+
"AlmztechErroTempoDePollingEsgotado",
|
|
70
|
+
"AlmztechErroProtocoloRejeitado",
|
|
71
|
+
"AlmztechErroWebhook",
|
|
72
|
+
]
|
almztech/erros.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Hierarquia de exceções do SDK (DESIGN.md seção 3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AlmztechErro(Exception):
|
|
9
|
+
"""Base de todas as exceções do SDK."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AlmztechErroApi(AlmztechErro):
|
|
13
|
+
"""Erro devolvido pela API (``{"mensagem": "..."}``). Não há código estruturado: ``mensagem`` é texto livre."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status_code: int, mensagem: str, corpo_bruto: Optional[str] = None):
|
|
16
|
+
super().__init__(f"A API da Almztech retornou {status_code}: {mensagem}")
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.mensagem = mensagem
|
|
19
|
+
self.corpo_bruto = corpo_bruto
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AlmztechErroValidacao(AlmztechErroApi):
|
|
23
|
+
"""422 (regra de negócio, inclusive franquia do plano esgotada) ou 400 (corpo recusado, com os erros por campo)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AlmztechErroAutenticacao(AlmztechErroApi):
|
|
27
|
+
"""401 — token ausente, inválido ou revogado, ou licença inválida para produção. Só a mensagem diferencia."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AlmztechErroAutorizacao(AlmztechErroApi):
|
|
31
|
+
"""403 — papel sem permissão para o endpoint."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AlmztechErroLimiteDeRequisicoes(AlmztechErroApi):
|
|
35
|
+
"""429 — limite de requisições. Não é retentado sozinho: ``retry_after`` (segundos) diz quanto esperar."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, mensagem: str, retry_after: Optional[float], corpo_bruto: Optional[str] = None):
|
|
38
|
+
super().__init__(429, mensagem, corpo_bruto)
|
|
39
|
+
self.retry_after = retry_after
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AlmztechErroServidor(AlmztechErroApi):
|
|
43
|
+
"""5xx — erro interno; em produção a mensagem pode vir genérica."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AlmztechErroRede(AlmztechErro):
|
|
47
|
+
"""Falha de rede, timeout ou DNS antes de qualquer resposta HTTP."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AlmztechErroTempoDePollingEsgotado(AlmztechErro):
|
|
51
|
+
"""O polling estourou o tempo total; ``ultimo_protocolo`` permite continuar manualmente."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, protocolo: str, ultimo_protocolo: Optional[dict[str, Any]]):
|
|
54
|
+
status = (ultimo_protocolo or {}).get("status", "desconhecido")
|
|
55
|
+
super().__init__(f"O protocolo {protocolo} não concluiu no tempo de espera (último status: {status}).")
|
|
56
|
+
self.protocolo = protocolo
|
|
57
|
+
self.ultimo_protocolo = ultimo_protocolo
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AlmztechErroProtocoloRejeitado(AlmztechErro):
|
|
61
|
+
"""O protocolo terminou em ``REJEITADO`` ou ``ERRO_INTERNO``; o motivo está em ``protocolo["mensagem"]``."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, protocolo: dict[str, Any]):
|
|
64
|
+
super().__init__(
|
|
65
|
+
f"O protocolo {protocolo.get('protocolo')} terminou em {protocolo.get('status')}: "
|
|
66
|
+
f"{protocolo.get('mensagem') or '(sem mensagem)'}"
|
|
67
|
+
)
|
|
68
|
+
self.protocolo = protocolo
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AlmztechErroWebhook(AlmztechErro):
|
|
72
|
+
"""Payload de webhook não reconhecido."""
|
almztech/filtros.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Filtros de ``consultar_com_filtros`` (DESIGN.md seção 7).
|
|
2
|
+
|
|
3
|
+
O valor vai sempre como texto: datas ``yyyy-MM-dd`` e decimais com ponto. Campo desconhecido ou valor vazio é
|
|
4
|
+
ignorado pela API (não filtra), por isso prefira as constantes e os construtores daqui.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Optional, Union
|
|
12
|
+
|
|
13
|
+
PRODUCAO = "PRD"
|
|
14
|
+
HOMOLOGACAO = "HML"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CamposFiltro:
|
|
18
|
+
PROTOCOLO = "protocolo"
|
|
19
|
+
CODIGO_NOTA = "codigoNota"
|
|
20
|
+
NOME_EMITENTE = "nomeEmitente"
|
|
21
|
+
CPF_CNPJ_EMITENTE = "cpfCnpjEmitente"
|
|
22
|
+
NUMERO_RPS = "numeroRps"
|
|
23
|
+
SERIE_RPS = "serieRps"
|
|
24
|
+
LOTE_RPS = "loteRps"
|
|
25
|
+
VALOR_TOTAL = "valorTotal"
|
|
26
|
+
#: Data ``yyyy-MM-dd``.
|
|
27
|
+
DATA_EMISSAO = "dataEmissao"
|
|
28
|
+
#: Vários status separados por ``|``; só com ``Igual``.
|
|
29
|
+
STATUS = "status"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Operador:
|
|
33
|
+
IGUAL = "Igual"
|
|
34
|
+
DIFERENTE = "Diferente"
|
|
35
|
+
MAIOR_QUE = "MaiorQue"
|
|
36
|
+
MAIOR_OU_IGUAL = "MaiorOuIgual"
|
|
37
|
+
MENOR_QUE = "MenorQue"
|
|
38
|
+
MENOR_OU_IGUAL = "MenorOuIgual"
|
|
39
|
+
ENTRE = "Entre"
|
|
40
|
+
CONTEM = "Contem"
|
|
41
|
+
INICIA_COM = "IniciaCom"
|
|
42
|
+
TERMINA_COM = "TerminaCom"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Numero = Union[int, float, Decimal]
|
|
46
|
+
Data = Union[dt.date, str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _texto_numero(valor: Numero) -> str:
|
|
50
|
+
return format(Decimal(str(valor)), "f") if not isinstance(valor, int) else str(valor)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _texto_data(valor: Data) -> str:
|
|
54
|
+
return valor if isinstance(valor, str) else valor.isoformat()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def filtro(campo: str, operador: str, valor: str, valor_final: Optional[str] = None) -> dict:
|
|
58
|
+
item = {"campo": campo, "operador": operador, "valor": valor}
|
|
59
|
+
if valor_final is not None:
|
|
60
|
+
item["valorFinal"] = valor_final
|
|
61
|
+
return item
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def numero(campo: str, operador: str, valor: Numero) -> dict:
|
|
65
|
+
return filtro(campo, operador, _texto_numero(valor))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def entre_valores(campo: str, inicio: Numero, fim: Numero) -> dict:
|
|
69
|
+
return filtro(campo, Operador.ENTRE, _texto_numero(inicio), _texto_numero(fim))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def data(operador: str, valor: Data) -> dict:
|
|
73
|
+
return filtro(CamposFiltro.DATA_EMISSAO, operador, _texto_data(valor))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def periodo(inicio: Data, fim: Data) -> dict:
|
|
77
|
+
return filtro(CamposFiltro.DATA_EMISSAO, Operador.ENTRE, _texto_data(inicio), _texto_data(fim))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def com_status(*status: str) -> dict:
|
|
81
|
+
return filtro(CamposFiltro.STATUS, Operador.IGUAL, "|".join(status))
|
almztech/log.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Mascaramento central de log (DESIGN.md seção 6).
|
|
2
|
+
|
|
3
|
+
Cobre request e response: ``senhaCertificado`` e ``senhaPrefeitura`` vêm em claro inclusive nas leituras de
|
|
4
|
+
emitente. Todo log automático do SDK passa por aqui.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
_CAMPOS_SENSIVEIS = {"apikey", "senha", "senhacertificado", "senhaprefeitura", "base64", "arquivobase64", "login"}
|
|
13
|
+
_MASCARA = "***"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def mascarar_api_key(api_key: str) -> str:
|
|
17
|
+
"""Mostra só os últimos 4 caracteres da api key."""
|
|
18
|
+
if not api_key:
|
|
19
|
+
return _MASCARA
|
|
20
|
+
return _MASCARA + (api_key[-4:] if len(api_key) > 4 else api_key)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mascarar_corpo_json(corpo: Optional[str]) -> str:
|
|
24
|
+
"""Cópia do JSON com todo campo sensível mascarado. Corpo não-JSON nunca é devolvido cru."""
|
|
25
|
+
if not corpo or not corpo.strip():
|
|
26
|
+
return ""
|
|
27
|
+
try:
|
|
28
|
+
return json.dumps(_mascarar(json.loads(corpo)), ensure_ascii=False)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return "<corpo não-JSON omitido da sanitização de log>"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _mascarar(valor: Any) -> Any:
|
|
34
|
+
if isinstance(valor, list):
|
|
35
|
+
return [_mascarar(item) for item in valor]
|
|
36
|
+
if isinstance(valor, dict):
|
|
37
|
+
return {
|
|
38
|
+
chave: _MASCARA if chave.lower() in _CAMPOS_SENSIVEIS else _mascarar(item)
|
|
39
|
+
for chave, item in valor.items()
|
|
40
|
+
}
|
|
41
|
+
return valor
|
almztech/modelos.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tipos do wire format (``TypedDict``): os dados são ``dict`` em camelCase, exatamente como o JSON da API.
|
|
2
|
+
|
|
3
|
+
Servem para o editor e o mypy; em runtime são dicionários comuns. Chaves com valor ``None`` não são enviadas
|
|
4
|
+
(a API aplica o padrão dela, e null num campo numérico seria recusado com 400).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from typing import Literal, TypedDict
|
|
13
|
+
except ImportError: # pragma: no cover
|
|
14
|
+
from typing_extensions import Literal, TypedDict # type: ignore
|
|
15
|
+
|
|
16
|
+
StatusProtocolo = Literal[
|
|
17
|
+
"EM_FILA", "EM_PROCESSAMENTO", "PENDENTE_PREFEITURA", "PROCESSADO_COM_SUCESSO", "REJEITADO", "CANCELADO", "ERRO_INTERNO"
|
|
18
|
+
]
|
|
19
|
+
StatusNota = Literal["EM_PROCESSAMENTO", "EMITIDA", "CANCELADA", "DENEGADA", "REJEITADA", "PENDENTE_PREFEITURA"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Endereco(TypedDict, total=False):
|
|
23
|
+
logradouro: str
|
|
24
|
+
numero: str
|
|
25
|
+
complemento: str
|
|
26
|
+
bairro: str
|
|
27
|
+
cep: str
|
|
28
|
+
codigoPais: int
|
|
29
|
+
codigoIBGEUF: int
|
|
30
|
+
#: Obrigatório para o tomador: sem ele a nota falha depois, no processamento assíncrono.
|
|
31
|
+
codigoIBGEMunicipio: int
|
|
32
|
+
siglaUF: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Destinatario(TypedDict, total=False):
|
|
36
|
+
cpfCnpj: str
|
|
37
|
+
razaoSocial: str
|
|
38
|
+
nomeFantasia: str
|
|
39
|
+
inscricaoEstadual: str
|
|
40
|
+
inscricaoMunicipal: str
|
|
41
|
+
telefone: str
|
|
42
|
+
endereco: Endereco
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Servico(TypedDict, total=False):
|
|
46
|
+
codigo: str
|
|
47
|
+
descricao: str
|
|
48
|
+
codigoTributacao: str
|
|
49
|
+
localPrestacao: str
|
|
50
|
+
responsavelPelaRetencao: int
|
|
51
|
+
#: ABRASF: 1 Exigível, 2 Não incidência, 3 Isenção, 4 Exportação, 5 Imunidade, 6/7 Suspensa.
|
|
52
|
+
exigibilidadeISS: int
|
|
53
|
+
tipoTributacao: int
|
|
54
|
+
municipioIncidencia: int
|
|
55
|
+
aliquotaISS: float
|
|
56
|
+
#: 1 = retido pelo tomador, 2 = não retido.
|
|
57
|
+
retemISS: int
|
|
58
|
+
quantidade: float
|
|
59
|
+
valorUnitario: float
|
|
60
|
+
valorDesconto: float
|
|
61
|
+
valorAcrescimo: float
|
|
62
|
+
codigoCnae: str
|
|
63
|
+
numeroProcesso: str
|
|
64
|
+
nbs: str
|
|
65
|
+
ibsCbs: dict
|
|
66
|
+
pisCofins: dict
|
|
67
|
+
totaisDosTributos: dict
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IdentificacaoDaNota(TypedDict, total=False):
|
|
71
|
+
#: Opcional com o controle automático de RPS do emitente: a API numera.
|
|
72
|
+
numeroRPS: str
|
|
73
|
+
serie: str
|
|
74
|
+
tipo: str
|
|
75
|
+
status: int
|
|
76
|
+
#: ``yyyy-MM-dd``.
|
|
77
|
+
dataEmissao: str
|
|
78
|
+
dataCompetencia: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class NotaFiscalResumida(TypedDict, total=False):
|
|
82
|
+
rps: IdentificacaoDaNota
|
|
83
|
+
destinatario: Destinatario
|
|
84
|
+
servicos: List[Servico]
|
|
85
|
+
#: ``[{"condicao": ..., "pagamentos": [{"parcela": 1, "dataPagamento": "yyyy-MM-dd", "valor": ...}]}]``.
|
|
86
|
+
condicoesPagamentos: List[dict]
|
|
87
|
+
observacao: str
|
|
88
|
+
#: Nome com o typo do schema real (``Subtituicao``): numeroRPS, numeroNota, serie, tipo, chaveNota, codigoMotivo, motivo.
|
|
89
|
+
subtituicaoRps: dict
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RecepcaoLote(TypedDict, total=False):
|
|
93
|
+
identificacaoEmitente: str
|
|
94
|
+
notas: List[NotaFiscalResumida]
|
|
95
|
+
#: ``False`` = homologação (``HML-``), ``True`` = produção (``PRD-``). É por lote.
|
|
96
|
+
enviarParaProducao: bool
|
|
97
|
+
urlWebhook: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ProtocoloNotaResponse(TypedDict, total=False):
|
|
101
|
+
codigoNota: int
|
|
102
|
+
tipoNota: str
|
|
103
|
+
numero: str
|
|
104
|
+
rps: str
|
|
105
|
+
serie: str
|
|
106
|
+
dataEmissao: str
|
|
107
|
+
situacao: StatusNota
|
|
108
|
+
codigoVerificacao: str
|
|
109
|
+
chaveAcesso: str
|
|
110
|
+
xml: str
|
|
111
|
+
pdf: str
|
|
112
|
+
dataHoraCancelamento: str
|
|
113
|
+
protocolo: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ProtocoloResponse(TypedDict, total=False):
|
|
117
|
+
protocolo: str
|
|
118
|
+
status: StatusProtocolo
|
|
119
|
+
mensagem: Optional[str]
|
|
120
|
+
notas: List[ProtocoloNotaResponse]
|
|
121
|
+
dadosCancelamento: dict
|
almztech/recursos.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Sub-clients por recurso (DESIGN.md seção 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, BinaryIO, Callable, Iterator, List, Optional, Union
|
|
9
|
+
from urllib.parse import quote, urlencode
|
|
10
|
+
|
|
11
|
+
from .erros import AlmztechErroProtocoloRejeitado, AlmztechErroTempoDePollingEsgotado
|
|
12
|
+
from .modelos import ProtocoloNotaResponse, ProtocoloResponse, RecepcaoLote
|
|
13
|
+
from .transporte import Transporte
|
|
14
|
+
|
|
15
|
+
#: Caminho de arquivo, bytes ou um arquivo aberto em modo binário.
|
|
16
|
+
Arquivo = Union[str, Path, bytes, bytearray, BinaryIO]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _base64(arquivo: Arquivo) -> str:
|
|
20
|
+
if isinstance(arquivo, (str, Path)):
|
|
21
|
+
conteudo = Path(arquivo).read_bytes()
|
|
22
|
+
elif isinstance(arquivo, (bytes, bytearray)):
|
|
23
|
+
conteudo = bytes(arquivo)
|
|
24
|
+
else:
|
|
25
|
+
conteudo = arquivo.read()
|
|
26
|
+
return base64.b64encode(conteudo).decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _seg(valor: Any) -> str:
|
|
30
|
+
return quote(str(valor), safe="")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotasFiscais:
|
|
34
|
+
"""Emissão, consulta, reconsulta, cancelamento, planilha e downloads de NFS-e (rota ``NFSe/*``)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, transporte: Transporte):
|
|
37
|
+
self._t = transporte
|
|
38
|
+
|
|
39
|
+
def emitir(self, lote: RecepcaoLote) -> ProtocoloResponse:
|
|
40
|
+
"""``POST /NFSe/Emitir``. Devolve só o protocolo em fila; acompanhe com ``aguardar_conclusao`` ou o webhook.
|
|
41
|
+
|
|
42
|
+
Produção exige licença válida e franquia disponível; homologação não consome franquia. **Nunca retentado
|
|
43
|
+
automaticamente**: a API não tem chave de idempotência e um reenvio pode duplicar a nota.
|
|
44
|
+
"""
|
|
45
|
+
return self._t.json("POST", "NFSe/Emitir", lote)
|
|
46
|
+
|
|
47
|
+
def aguardar_conclusao(
|
|
48
|
+
self,
|
|
49
|
+
protocolo: str,
|
|
50
|
+
intervalo_inicial: float = 2.0,
|
|
51
|
+
multiplicador: float = 1.5,
|
|
52
|
+
intervalo_maximo: float = 30.0,
|
|
53
|
+
timeout_total: float = 300.0,
|
|
54
|
+
a_cada_tentativa: Optional[Callable[[ProtocoloResponse], None]] = None,
|
|
55
|
+
) -> ProtocoloResponse:
|
|
56
|
+
"""Consulta o protocolo com intervalos crescentes até um status terminal.
|
|
57
|
+
|
|
58
|
+
``REJEITADO``/``ERRO_INTERNO`` lançam ``AlmztechErroProtocoloRejeitado``; estourar ``timeout_total``
|
|
59
|
+
(segundos) lança ``AlmztechErroTempoDePollingEsgotado`` com o último protocolo.
|
|
60
|
+
"""
|
|
61
|
+
inicio = time.monotonic()
|
|
62
|
+
atraso = intervalo_inicial
|
|
63
|
+
while True:
|
|
64
|
+
ultimo = self.consultar_protocolo(protocolo)
|
|
65
|
+
if a_cada_tentativa:
|
|
66
|
+
a_cada_tentativa(ultimo)
|
|
67
|
+
|
|
68
|
+
status = ultimo.get("status")
|
|
69
|
+
if status in ("PROCESSADO_COM_SUCESSO", "CANCELADO"):
|
|
70
|
+
return ultimo
|
|
71
|
+
if status in ("REJEITADO", "ERRO_INTERNO"):
|
|
72
|
+
raise AlmztechErroProtocoloRejeitado(dict(ultimo))
|
|
73
|
+
if time.monotonic() - inicio >= timeout_total:
|
|
74
|
+
raise AlmztechErroTempoDePollingEsgotado(protocolo, dict(ultimo))
|
|
75
|
+
|
|
76
|
+
time.sleep(atraso)
|
|
77
|
+
atraso = min(atraso * multiplicador, intervalo_maximo)
|
|
78
|
+
|
|
79
|
+
def consultar_protocolo(self, protocolo: str) -> ProtocoloResponse:
|
|
80
|
+
return self._ler(f"NFSe/Consultar/Protocolo/{_seg(protocolo)}")
|
|
81
|
+
|
|
82
|
+
def consultar_por_codigo(self, codigo_nota: int) -> ProtocoloNotaResponse:
|
|
83
|
+
return self._ler(f"NFSe/Consultar/{codigo_nota}")
|
|
84
|
+
|
|
85
|
+
def consultar_por_numero(self, numero_nota: str) -> ProtocoloNotaResponse:
|
|
86
|
+
"""Pelo número da NFS-e na prefeitura; havendo mais de uma entre os seus emitentes, vem a mais recente."""
|
|
87
|
+
return self._ler(f"NFSe/Consultar/Numero/{_seg(numero_nota)}")
|
|
88
|
+
|
|
89
|
+
def consultar_lista(self, ambiente: str) -> List[dict]:
|
|
90
|
+
"""As 50 notas mais recentes do ambiente (``PRODUCAO``/``HOMOLOGACAO`` de ``almztech.filtros``)."""
|
|
91
|
+
return self._ler(f"NFSe/ConsultarLista/{_seg(ambiente)}")
|
|
92
|
+
|
|
93
|
+
def consultar_lista_por_termo(self, ambiente: str, termo: str) -> List[dict]:
|
|
94
|
+
"""Busca por CNPJ, código, número, razão social, nome fantasia ou protocolo (até 50)."""
|
|
95
|
+
return self._ler(f"NFSe/ConsultarListaPorTermo/{_seg(ambiente)}/{_seg(termo)}")
|
|
96
|
+
|
|
97
|
+
def consultar_com_filtros(
|
|
98
|
+
self,
|
|
99
|
+
ambiente: str,
|
|
100
|
+
filtros: Optional[List[dict]] = None,
|
|
101
|
+
filtro_rapido: Optional[str] = None,
|
|
102
|
+
pagina: int = 1,
|
|
103
|
+
limite_da_pagina: int = 50,
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""POST por causa do corpo, mas é leitura (retentado como os GET). Limite real de 100 por página.
|
|
106
|
+
|
|
107
|
+
Devolve ``notas``, ``paginaAtual``, ``totalDeRegistros``, os totais por status e ``contagensPorAba``.
|
|
108
|
+
"""
|
|
109
|
+
corpo = {
|
|
110
|
+
"ambiente": ambiente,
|
|
111
|
+
"filtroRapido": filtro_rapido,
|
|
112
|
+
"filtros": filtros,
|
|
113
|
+
"pagina": pagina,
|
|
114
|
+
"limiteDaPagina": limite_da_pagina,
|
|
115
|
+
}
|
|
116
|
+
return self._t.json("POST", "NFSe/ConsultarComFiltros", corpo, permite_retry=True)
|
|
117
|
+
|
|
118
|
+
def paginas(
|
|
119
|
+
self, ambiente: str, filtros: Optional[List[dict]] = None, filtro_rapido: Optional[str] = None, limite_da_pagina: int = 50
|
|
120
|
+
) -> Iterator[dict]:
|
|
121
|
+
"""Percorre todas as páginas de ``consultar_com_filtros``, nota a nota."""
|
|
122
|
+
pagina = 1
|
|
123
|
+
while True:
|
|
124
|
+
resultado = self.consultar_com_filtros(ambiente, filtros, filtro_rapido, pagina, limite_da_pagina)
|
|
125
|
+
notas = resultado.get("notas") or []
|
|
126
|
+
yield from notas
|
|
127
|
+
total_de_paginas = -(-resultado.get("totalDeRegistros", 0) // max(1, limite_da_pagina))
|
|
128
|
+
if not notas or pagina >= total_de_paginas:
|
|
129
|
+
return
|
|
130
|
+
pagina += 1
|
|
131
|
+
|
|
132
|
+
def reconsultar(self, codigo_nota: int) -> ProtocoloNotaResponse:
|
|
133
|
+
"""Consulta a nota de novo na prefeitura e atualiza a situação local. Repetir é seguro."""
|
|
134
|
+
return self._ler(f"NFSe/Reconsultar/{codigo_nota}")
|
|
135
|
+
|
|
136
|
+
def reconsultar_por_chave_acesso(self, chave_acesso: str, enviar_para_producao: bool) -> ProtocoloNotaResponse:
|
|
137
|
+
"""Reconsulta pela chave de acesso do Emissor Nacional; atualiza a nota local de mesmo número."""
|
|
138
|
+
return self._t.json(
|
|
139
|
+
"POST", f"NFSe/Reconsultar/ChaveAcesso/{_seg(chave_acesso)}", {"enviarParaProducao": enviar_para_producao}, permite_retry=True
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def cancelar(self, codigo_nota: int, url_webhook: Optional[str] = None) -> ProtocoloResponse:
|
|
143
|
+
"""``POST /NFSe/Cancelar/{codigo}``. Assíncrono como a emissão; não retentado."""
|
|
144
|
+
return self._t.json("POST", f"NFSe/Cancelar/{codigo_nota}", {"urlWebhook": url_webhook})
|
|
145
|
+
|
|
146
|
+
def baixar_xml(self, codigo_nota: int) -> bytes:
|
|
147
|
+
"""Bytes do XML; gravar em disco fica com você."""
|
|
148
|
+
return self._t.bytes(f"NFSe/XML/{codigo_nota}")
|
|
149
|
+
|
|
150
|
+
def baixar_pdf(self, codigo_nota: int) -> bytes:
|
|
151
|
+
return self._t.bytes(f"NFSe/PDF/{codigo_nota}")
|
|
152
|
+
|
|
153
|
+
def baixar_modelo_planilha(self) -> bytes:
|
|
154
|
+
"""O ``.xlsx`` modelo de ``importar_planilha``."""
|
|
155
|
+
return self._t.bytes("NFSe/ModeloPlanilha")
|
|
156
|
+
|
|
157
|
+
def importar_planilha(
|
|
158
|
+
self, identificacao_emitente: str, planilha: Arquivo, enviar_para_producao: bool, url_webhook: Optional[str] = None
|
|
159
|
+
) -> dict:
|
|
160
|
+
"""Emite as notas de uma planilha no formato do modelo, com os padrões fiscais do emitente.
|
|
161
|
+
|
|
162
|
+
O emitente precisa do controle automático de RPS. Devolve ``protocolos`` (um por lote) e ``erros`` por linha.
|
|
163
|
+
Não retentado (emite notas).
|
|
164
|
+
"""
|
|
165
|
+
corpo = {
|
|
166
|
+
"arquivoBase64": _base64(planilha),
|
|
167
|
+
"identificacaoEmitente": identificacao_emitente,
|
|
168
|
+
"enviarParaProducao": enviar_para_producao,
|
|
169
|
+
"urlWebhook": url_webhook,
|
|
170
|
+
}
|
|
171
|
+
return self._t.json("POST", "NFSe/ImportarPlanilha", corpo)
|
|
172
|
+
|
|
173
|
+
def _ler(self, caminho: str) -> Any:
|
|
174
|
+
return self._t.json("GET", caminho, permite_retry=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Emitentes:
|
|
178
|
+
"""CRUD de emitente e upload do certificado A1 (rota ``Emitente/*``).
|
|
179
|
+
|
|
180
|
+
``atualizar`` grava todos os campos, inclusive os padrões fiscais: parta do emitente consultado. As leituras
|
|
181
|
+
trazem ``senhaCertificado``/``senhaPrefeitura`` em claro — nunca logue esses dicionários.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, transporte: Transporte):
|
|
185
|
+
self._t = transporte
|
|
186
|
+
|
|
187
|
+
def consultar(self) -> List[dict]:
|
|
188
|
+
return self._t.json("GET", "Emitente/Consultar", permite_retry=True)
|
|
189
|
+
|
|
190
|
+
def consultar_por_codigo(self, codigo: int) -> dict:
|
|
191
|
+
return self._t.json("GET", f"Emitente/Consultar/{codigo}", permite_retry=True)
|
|
192
|
+
|
|
193
|
+
def consultar_por_cpf_cnpj(self, cpf_cnpj: str) -> dict:
|
|
194
|
+
return self._t.json("GET", f"Emitente/ConsultarCpfCnpj/{_seg(cpf_cnpj)}", permite_retry=True)
|
|
195
|
+
|
|
196
|
+
def consultar_por_termo(self, termo: str) -> List[dict]:
|
|
197
|
+
return self._t.json("GET", f"Emitente/ConsultaPorTermo/{_seg(termo)}", permite_retry=True)
|
|
198
|
+
|
|
199
|
+
def cadastrar(self, emitente: dict) -> dict:
|
|
200
|
+
"""Devolve ``{"mensagem", "cpfCnpj", "codigo"}``."""
|
|
201
|
+
return self._t.json("POST", "Emitente/Cadastrar", emitente)
|
|
202
|
+
|
|
203
|
+
def atualizar(self, emitente: dict) -> dict:
|
|
204
|
+
return self._t.json("PUT", "Emitente/Atualizar", emitente)
|
|
205
|
+
|
|
206
|
+
def excluir(self, codigo: int) -> dict:
|
|
207
|
+
return self._t.json("DELETE", f"Emitente/Excluir/{codigo}")
|
|
208
|
+
|
|
209
|
+
def upload_certificado(self, cnpj_emissor: str, certificado_pfx: Arquivo, senha: str) -> dict:
|
|
210
|
+
"""Envia o ``.pfx``, convertido para Base64 aqui. Devolve ``chaveCertificado``, ``vencimento`` e ``aviso``."""
|
|
211
|
+
corpo = {"cnpjEmissor": cnpj_emissor, "base64": _base64(certificado_pfx), "senha": senha}
|
|
212
|
+
return self._t.json("POST", "Emitente/UploadCertificado", corpo)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Webhooks:
|
|
216
|
+
"""CRUD e teste de webhook (fisicamente em ``Emitente/Webhook*``). Evento: 0 Emissão, 1 Cancelamento, 2 Rejeição."""
|
|
217
|
+
|
|
218
|
+
EMISSAO = 0
|
|
219
|
+
CANCELAMENTO = 1
|
|
220
|
+
REJEICAO = 2
|
|
221
|
+
|
|
222
|
+
def __init__(self, transporte: Transporte):
|
|
223
|
+
self._t = transporte
|
|
224
|
+
|
|
225
|
+
def obter(self, codigo: str) -> dict:
|
|
226
|
+
return self._t.json("GET", f"Emitente/Webhook/{_seg(codigo)}", permite_retry=True)
|
|
227
|
+
|
|
228
|
+
def listar_por_emitente(self, codigo_emitente: int) -> List[dict]:
|
|
229
|
+
return self._t.json("GET", f"Emitente/Webhooks/{codigo_emitente}", permite_retry=True)
|
|
230
|
+
|
|
231
|
+
def criar(self, codigo_emitente: int, evento: int, url: str, ativo: bool = True) -> dict:
|
|
232
|
+
"""Não retentado: a API não barra URL duplicada, e um retry cego criaria dois registros."""
|
|
233
|
+
corpo = {"codigoEmitente": codigo_emitente, "evento": evento, "url": url, "ativo": ativo}
|
|
234
|
+
return self._t.json("POST", "Emitente/Webhook", corpo)
|
|
235
|
+
|
|
236
|
+
def atualizar(self, webhook: dict) -> dict:
|
|
237
|
+
return self._t.json("PUT", "Emitente/Webhook", webhook)
|
|
238
|
+
|
|
239
|
+
def excluir(self, codigo: str) -> dict:
|
|
240
|
+
return self._t.json("DELETE", f"Emitente/Webhook/{_seg(codigo)}")
|
|
241
|
+
|
|
242
|
+
def testar(self, url: str, evento: int) -> dict:
|
|
243
|
+
"""Dispara um payload de exemplo do evento para a URL, sem precisar de webhook cadastrado."""
|
|
244
|
+
return self._t.json("POST", "Emitente/Webhook/Teste", {"url": url, "evento": evento})
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Municipios:
|
|
248
|
+
"""Municípios já homologados (``CoberturaNacional/MunicipiosHomologados``)."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, transporte: Transporte):
|
|
251
|
+
self._t = transporte
|
|
252
|
+
|
|
253
|
+
def homologados(self, codigo_ibge: Optional[int] = None, termo_de_busca: Optional[str] = None) -> List[dict]:
|
|
254
|
+
"""Pelo código IBGE ou por um termo; sem nenhum dos dois vem vazio. Até 20 ``{"id": <IBGE>, "text"}``."""
|
|
255
|
+
parametros = {}
|
|
256
|
+
if codigo_ibge is not None:
|
|
257
|
+
parametros["codigo"] = codigo_ibge
|
|
258
|
+
if termo_de_busca:
|
|
259
|
+
parametros["termoDeBusca"] = termo_de_busca
|
|
260
|
+
sufixo = f"?{urlencode(parametros)}" if parametros else ""
|
|
261
|
+
return self._t.json("GET", f"CoberturaNacional/MunicipiosHomologados{sufixo}", permite_retry=True)
|
almztech/transporte.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""HTTP do SDK: header ``x-api-key``, retry só em leituras, mapeamento de erros e JSON sem nulos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import Any, Callable, Mapping, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from .erros import (
|
|
16
|
+
AlmztechErroApi,
|
|
17
|
+
AlmztechErroAutenticacao,
|
|
18
|
+
AlmztechErroAutorizacao,
|
|
19
|
+
AlmztechErroLimiteDeRequisicoes,
|
|
20
|
+
AlmztechErroRede,
|
|
21
|
+
AlmztechErroServidor,
|
|
22
|
+
AlmztechErroValidacao,
|
|
23
|
+
)
|
|
24
|
+
from .log import mascarar_api_key, mascarar_corpo_json
|
|
25
|
+
|
|
26
|
+
VERSAO_SDK = "0.2.0-beta.1"
|
|
27
|
+
|
|
28
|
+
# (status, headers, corpo) — o que um envio HTTP devolve.
|
|
29
|
+
RespostaHttp = Tuple[int, Mapping[str, str], bytes]
|
|
30
|
+
|
|
31
|
+
# Envio HTTP injetável (proxy corporativo, instrumentação, testes): (método, url, headers, corpo, timeout).
|
|
32
|
+
# Deve devolver a resposta inclusive em 4xx/5xx e lançar OSError só em falha de rede.
|
|
33
|
+
EnvioHttp = Callable[[str, str, Mapping[str, str], Optional[bytes], float], RespostaHttp]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class OpcoesDeRetry:
|
|
38
|
+
"""Só operações seguras (leituras) são retentadas, e só em falha de rede ou 5xx (DESIGN.md seção 4)."""
|
|
39
|
+
|
|
40
|
+
tentativas: int = 3
|
|
41
|
+
intervalo_inicial: float = 0.5
|
|
42
|
+
multiplicador: float = 2.0
|
|
43
|
+
intervalo_maximo: float = 10.0
|
|
44
|
+
|
|
45
|
+
def atraso(self, tentativa: int) -> float:
|
|
46
|
+
base = min(self.intervalo_inicial * self.multiplicador ** (tentativa - 1), self.intervalo_maximo)
|
|
47
|
+
return base + random.random() * base * 0.2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class OpcoesDoClient:
|
|
52
|
+
#: Padrão: produção. O ambiente fiscal é por chamada (``enviarParaProducao``), não por URL.
|
|
53
|
+
base_url: str = "https://api.almztech.com.br"
|
|
54
|
+
#: Timeout de rede por chamada, em segundos. Não é o tempo de processamento da prefeitura.
|
|
55
|
+
timeout: float = 30.0
|
|
56
|
+
retry: OpcoesDeRetry = field(default_factory=OpcoesDeRetry)
|
|
57
|
+
envio_http: Optional[EnvioHttp] = None
|
|
58
|
+
#: Recebe só mensagens já mascaradas.
|
|
59
|
+
logger: Optional[Callable[[str], None]] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _envio_urllib(metodo: str, url: str, headers: Mapping[str, str], corpo: Optional[bytes], timeout: float) -> RespostaHttp:
|
|
63
|
+
requisicao = urllib.request.Request(url, data=corpo, headers=dict(headers), method=metodo)
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(requisicao, timeout=timeout) as resposta:
|
|
66
|
+
return resposta.status, dict(resposta.headers), resposta.read()
|
|
67
|
+
except urllib.error.HTTPError as erro:
|
|
68
|
+
return erro.code, dict(erro.headers or {}), erro.read()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sem_nulos(valor: Any) -> Any:
|
|
72
|
+
"""Campo numérico da API (tipo não anulável) recusa null com 400: omitir deixa valer o padrão dela."""
|
|
73
|
+
if isinstance(valor, dict):
|
|
74
|
+
return {chave: _sem_nulos(item) for chave, item in valor.items() if item is not None}
|
|
75
|
+
if isinstance(valor, (list, tuple)):
|
|
76
|
+
return [_sem_nulos(item) for item in valor]
|
|
77
|
+
return valor
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Transporte:
|
|
81
|
+
def __init__(self, api_key: str, opcoes: Optional[OpcoesDoClient] = None):
|
|
82
|
+
if not api_key or not api_key.strip():
|
|
83
|
+
raise ValueError("api_key é obrigatório.")
|
|
84
|
+
self._api_key = api_key
|
|
85
|
+
self._opcoes = opcoes or OpcoesDoClient()
|
|
86
|
+
self._envio = self._opcoes.envio_http or _envio_urllib
|
|
87
|
+
self._base_url = self._opcoes.base_url.rstrip("/")
|
|
88
|
+
self._log(f"AlmztechClient configurado (apiKey={mascarar_api_key(api_key)}, baseUrl={self._base_url})")
|
|
89
|
+
|
|
90
|
+
def json(self, metodo: str, caminho: str, corpo: Any = None, permite_retry: bool = False) -> Any:
|
|
91
|
+
status, headers, conteudo = self._enviar(metodo, caminho, corpo, permite_retry)
|
|
92
|
+
texto = conteudo.decode("utf-8") if conteudo else ""
|
|
93
|
+
self._log(f"<- {status} {metodo} {caminho} {mascarar_corpo_json(texto)}")
|
|
94
|
+
if status >= 400:
|
|
95
|
+
raise _mapear_erro(status, headers, texto)
|
|
96
|
+
return json.loads(texto) if texto.strip() else None
|
|
97
|
+
|
|
98
|
+
def bytes(self, caminho: str) -> bytes:
|
|
99
|
+
status, headers, conteudo = self._enviar("GET", caminho, None, True)
|
|
100
|
+
if status >= 400:
|
|
101
|
+
raise _mapear_erro(status, headers, conteudo.decode("utf-8", errors="replace"))
|
|
102
|
+
return conteudo
|
|
103
|
+
|
|
104
|
+
def _enviar(self, metodo: str, caminho: str, corpo: Any, permite_retry: bool) -> RespostaHttp:
|
|
105
|
+
retry = self._opcoes.retry
|
|
106
|
+
tentativas = max(1, retry.tentativas) if permite_retry else 1
|
|
107
|
+
dados = None if corpo is None else json.dumps(_sem_nulos(corpo), ensure_ascii=False).encode("utf-8")
|
|
108
|
+
headers = {
|
|
109
|
+
"x-api-key": self._api_key,
|
|
110
|
+
"User-Agent": f"almztech-python-sdk/{VERSAO_SDK}",
|
|
111
|
+
"Accept": "application/json",
|
|
112
|
+
}
|
|
113
|
+
if dados is not None:
|
|
114
|
+
headers["Content-Type"] = "application/json"
|
|
115
|
+
|
|
116
|
+
tentativa = 1
|
|
117
|
+
while True:
|
|
118
|
+
if dados is not None:
|
|
119
|
+
self._log(f"-> {metodo} {caminho} {mascarar_corpo_json(dados.decode('utf-8'))}")
|
|
120
|
+
try:
|
|
121
|
+
resposta = self._envio(metodo, f"{self._base_url}/{caminho}", headers, dados, self._opcoes.timeout)
|
|
122
|
+
except (OSError, socket.timeout) as erro:
|
|
123
|
+
if tentativa >= tentativas:
|
|
124
|
+
raise AlmztechErroRede(f"Falha de rede chamando {metodo} {caminho}: {erro}") from erro
|
|
125
|
+
time.sleep(retry.atraso(tentativa))
|
|
126
|
+
tentativa += 1
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if resposta[0] >= 500 and tentativa < tentativas:
|
|
130
|
+
time.sleep(retry.atraso(tentativa))
|
|
131
|
+
tentativa += 1
|
|
132
|
+
continue
|
|
133
|
+
return resposta
|
|
134
|
+
|
|
135
|
+
def _log(self, mensagem: str) -> None:
|
|
136
|
+
if self._opcoes.logger:
|
|
137
|
+
self._opcoes.logger(mensagem)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _mapear_erro(status: int, headers: Mapping[str, str], corpo: str) -> AlmztechErroApi:
|
|
141
|
+
mensagem = _extrair_mensagem(corpo)
|
|
142
|
+
if status in (400, 422):
|
|
143
|
+
return AlmztechErroValidacao(status, mensagem, corpo)
|
|
144
|
+
if status == 401:
|
|
145
|
+
return AlmztechErroAutenticacao(status, mensagem, corpo)
|
|
146
|
+
if status == 403:
|
|
147
|
+
return AlmztechErroAutorizacao(status, mensagem, corpo)
|
|
148
|
+
if status == 429:
|
|
149
|
+
retry_after = next((valor for chave, valor in headers.items() if chave.lower() == "retry-after"), None)
|
|
150
|
+
return AlmztechErroLimiteDeRequisicoes(mensagem, _ler_retry_after(retry_after), corpo)
|
|
151
|
+
if status >= 500:
|
|
152
|
+
return AlmztechErroServidor(status, mensagem, corpo)
|
|
153
|
+
return AlmztechErroApi(status, mensagem, corpo)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ler_retry_after(valor: Optional[str]) -> Optional[float]:
|
|
157
|
+
if not valor:
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
return float(valor)
|
|
161
|
+
except ValueError:
|
|
162
|
+
pass
|
|
163
|
+
try:
|
|
164
|
+
return max(0.0, parsedate_to_datetime(valor).timestamp() - time.time())
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _extrair_mensagem(corpo: str) -> str:
|
|
170
|
+
"""``{mensagem}`` é o formato de erro da API; o 400 do ASP.NET vem como ProblemDetails, com ``errors`` por campo."""
|
|
171
|
+
if not corpo.strip():
|
|
172
|
+
return "(sem corpo de resposta)"
|
|
173
|
+
try:
|
|
174
|
+
dados = json.loads(corpo)
|
|
175
|
+
except ValueError:
|
|
176
|
+
return corpo
|
|
177
|
+
if isinstance(dados, dict):
|
|
178
|
+
if isinstance(dados.get("mensagem"), str):
|
|
179
|
+
return dados["mensagem"]
|
|
180
|
+
if isinstance(dados.get("errors"), dict):
|
|
181
|
+
return "; ".join(
|
|
182
|
+
f"{campo}: {erro}"
|
|
183
|
+
for campo, erros in dados["errors"].items()
|
|
184
|
+
for erro in (erros if isinstance(erros, list) else [erros])
|
|
185
|
+
)
|
|
186
|
+
return corpo
|
almztech/webhook.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Leitura do payload recebido no seu endpoint de webhook (DESIGN.md seção 5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
from .erros import AlmztechErroWebhook
|
|
10
|
+
from .modelos import ProtocoloResponse
|
|
11
|
+
|
|
12
|
+
EMISSAO = "Emissao"
|
|
13
|
+
CANCELAMENTO = "Cancelamento"
|
|
14
|
+
REJEICAO = "Rejeicao"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class EventoWebhook:
|
|
19
|
+
#: ``EMISSAO``, ``CANCELAMENTO`` ou ``REJEICAO``.
|
|
20
|
+
tipo: str
|
|
21
|
+
#: Payload completo (mesmo schema do polling).
|
|
22
|
+
protocolo: ProtocoloResponse
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ler_webhook(corpo: Union[str, bytes, dict]) -> EventoWebhook:
|
|
26
|
+
"""Classifica o corpo: ``notas`` não vazio → Emissão; senão ``dadosCancelamento`` → Cancelamento; senão status
|
|
27
|
+
``REJEITADO`` → Rejeição (motivo em ``mensagem``); senão lança ``AlmztechErroWebhook``.
|
|
28
|
+
|
|
29
|
+
Não verifica autenticidade: a API não assina o webhook. Proteger o endpoint (token secreto na URL, allowlist
|
|
30
|
+
de IP, mTLS) é responsabilidade de quem o recebe.
|
|
31
|
+
"""
|
|
32
|
+
if isinstance(corpo, (str, bytes)):
|
|
33
|
+
if not corpo.strip():
|
|
34
|
+
raise AlmztechErroWebhook("Corpo do webhook vazio.")
|
|
35
|
+
try:
|
|
36
|
+
protocolo = json.loads(corpo)
|
|
37
|
+
except ValueError as erro:
|
|
38
|
+
raise AlmztechErroWebhook("Corpo do webhook não é um JSON válido.") from erro
|
|
39
|
+
else:
|
|
40
|
+
protocolo = corpo
|
|
41
|
+
|
|
42
|
+
if not isinstance(protocolo, dict):
|
|
43
|
+
raise AlmztechErroWebhook("Corpo do webhook não é um objeto.")
|
|
44
|
+
if protocolo.get("notas"):
|
|
45
|
+
return EventoWebhook(EMISSAO, protocolo) # type: ignore[arg-type]
|
|
46
|
+
if protocolo.get("dadosCancelamento"):
|
|
47
|
+
return EventoWebhook(CANCELAMENTO, protocolo) # type: ignore[arg-type]
|
|
48
|
+
if protocolo.get("status") == "REJEITADO":
|
|
49
|
+
return EventoWebhook(REJEICAO, protocolo) # type: ignore[arg-type]
|
|
50
|
+
raise AlmztechErroWebhook(
|
|
51
|
+
f"Payload de webhook não reconhecido (protocolo='{protocolo.get('protocolo')}', status='{protocolo.get('status')}'): "
|
|
52
|
+
"sem 'notas', sem 'dadosCancelamento' e status diferente de REJEITADO."
|
|
53
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: almztech-sdk
|
|
3
|
+
Version: 0.2.0b1
|
|
4
|
+
Summary: SDK oficial Python para a API da Almztech (emissão, consulta, reconsulta e cancelamento de NFS-e, importação por planilha, Emitente, Webhook).
|
|
5
|
+
Author: ALMZTECH SOFTWARES LTDA
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://docs.almztech.com.br
|
|
8
|
+
Project-URL: Documentation, https://docs.almztech.com.br
|
|
9
|
+
Project-URL: Source, https://gitlab.com/almztech/sdks
|
|
10
|
+
Keywords: nfse,nfs-e,nota-fiscal,nota-fiscal-de-servico,almztech,sdk
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: Portuguese (Brazilian)
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# almztech-sdk (Python)
|
|
23
|
+
|
|
24
|
+
SDK oficial da [Almztech](https://almztech.com.br) para Python. Ele cobre a API de NFS-e:
|
|
25
|
+
- emissão, consulta, reconsulta e cancelamento de notas, inclusive por planilha;
|
|
26
|
+
- emitentes e certificados;
|
|
27
|
+
- webhooks;
|
|
28
|
+
- municípios homologados.
|
|
29
|
+
|
|
30
|
+
> Versão beta: a API pública do SDK ainda pode mudar até a 1.0.
|
|
31
|
+
|
|
32
|
+
## Instalação
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install --pre almztech-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- Python 3.9+.
|
|
39
|
+
- **Sem dependências de runtime**: usa `urllib` e `json` da biblioteca padrão (dá para injetar outro envio HTTP em `OpcoesDoClient.envio_http`).
|
|
40
|
+
- Os dados são `dict` no formato exato do JSON da API, em camelCase, com `TypedDict` em `almztech.modelos` para o editor. Chaves com `None` não são enviadas.
|
|
41
|
+
|
|
42
|
+
## Uso
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import os
|
|
46
|
+
from almztech import AlmztechClient, AlmztechErroProtocoloRejeitado, AlmztechErroValidacao, filtros, ler_webhook
|
|
47
|
+
|
|
48
|
+
# A api key está no Portal (portal.almztech.com.br), em Primeiros passos. Guarde-a na configuração do seu
|
|
49
|
+
# sistema, nunca no código.
|
|
50
|
+
client = AlmztechClient(os.environ["ALMZTECH_API_KEY"])
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
protocolo = client.notas_fiscais.emitir({
|
|
54
|
+
"identificacaoEmitente": "12345678000190",
|
|
55
|
+
"enviarParaProducao": False, # homologação (HML-...): não consome a franquia
|
|
56
|
+
"notas": [{
|
|
57
|
+
"rps": {"numeroRPS": "1001", "serie": "1", "tipo": "1", "dataEmissao": "2026-10-31"},
|
|
58
|
+
"destinatario": {
|
|
59
|
+
"cpfCnpj": "11222333000181",
|
|
60
|
+
"razaoSocial": "Cliente Exemplo Ltda",
|
|
61
|
+
"endereco": {"cep": "50030170", "codigoIBGEMunicipio": 2611606},
|
|
62
|
+
},
|
|
63
|
+
"servicos": [{"codigo": "0105", "descricao": "Licenciamento de software", "quantidade": 1,
|
|
64
|
+
"valorUnitario": 1500, "aliquotaISS": 2, "retemISS": 2}],
|
|
65
|
+
}],
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
resultado = client.notas_fiscais.aguardar_conclusao(protocolo["protocolo"])
|
|
69
|
+
for nota in resultado.get("notas", []):
|
|
70
|
+
pdf = client.notas_fiscais.baixar_pdf(nota["codigoNota"]) # bytes
|
|
71
|
+
except AlmztechErroProtocoloRejeitado as erro:
|
|
72
|
+
print("Rejeitada:", erro.protocolo.get("mensagem"))
|
|
73
|
+
except AlmztechErroValidacao as erro:
|
|
74
|
+
print("Recusada:", erro.mensagem)
|
|
75
|
+
|
|
76
|
+
# No seu endpoint de webhook (a API não assina o payload: proteja a URL):
|
|
77
|
+
evento = ler_webhook(corpo_recebido) # evento.tipo: "Emissao" | "Cancelamento" | "Rejeicao"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Consulta com filtros e paginação automática:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
for nota in client.notas_fiscais.paginas(
|
|
84
|
+
filtros.PRODUCAO,
|
|
85
|
+
[filtros.periodo("2026-10-01", "2026-10-31"), filtros.com_status("EMITIDA")],
|
|
86
|
+
):
|
|
87
|
+
print(nota["numeroNota"], nota["valorTotal"])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Também estão disponíveis:
|
|
91
|
+
- **Notas:** `consultar_por_numero`, `reconsultar`, `reconsultar_por_chave_acesso`, `cancelar`, `baixar_xml`, `baixar_modelo_planilha` e `importar_planilha` (aceita caminho, bytes ou arquivo aberto).
|
|
92
|
+
- **Emitentes:** `client.emitentes`, inclusive `upload_certificado`.
|
|
93
|
+
- **Webhooks e municípios:** `client.webhooks` e `client.municipios`.
|
|
94
|
+
|
|
95
|
+
Exceções:
|
|
96
|
+
- `AlmztechErroValidacao` (422 e 400);
|
|
97
|
+
- `AlmztechErroAutenticacao` (401);
|
|
98
|
+
- `AlmztechErroAutorizacao` (403);
|
|
99
|
+
- `AlmztechErroLimiteDeRequisicoes` (429, com `retry_after`);
|
|
100
|
+
- `AlmztechErroServidor` (5xx);
|
|
101
|
+
- `AlmztechErroRede`;
|
|
102
|
+
- `AlmztechErroTempoDePollingEsgotado`;
|
|
103
|
+
- `AlmztechErroProtocoloRejeitado`;
|
|
104
|
+
- `AlmztechErroWebhook`.
|
|
105
|
+
|
|
106
|
+
Só as leituras são retentadas automaticamente. `emitir`, `cancelar` e `importar_planilha` nunca são.
|
|
107
|
+
|
|
108
|
+
## Links
|
|
109
|
+
|
|
110
|
+
- [Documentação da API](https://docs.almztech.com.br)
|
|
111
|
+
- [Código-fonte](https://gitlab.com/almztech/sdks/-/tree/main/python). Os SDKs das outras linguagens (.NET, Node.js,
|
|
112
|
+
PHP, Java e Ruby) seguem o mesmo [contrato](https://gitlab.com/almztech/sdks/-/blob/main/DESIGN.md).
|
|
113
|
+
|
|
114
|
+
## Licença
|
|
115
|
+
|
|
116
|
+
MIT, © 2026 ALMZTECH SOFTWARES LTDA. Veja [LICENSE](https://gitlab.com/almztech/sdks/-/blob/main/python/LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
almztech/__init__.py,sha256=yinVEKS8gFF2iqxhfxGjoHEy-_8lsixNjPKkj1ACals,2023
|
|
2
|
+
almztech/erros.py,sha256=rrX0JPnnkK6wMWCg46tHs3OmOtiHiNwacGH6Kj1VfpI,2761
|
|
3
|
+
almztech/filtros.py,sha256=5HPPeKduxae8Z0qmNozyniokxZ4zBf1HJo11FWFqAjg,2350
|
|
4
|
+
almztech/log.py,sha256=qCfsysu0c0yRk652fLZAmItLzh8D4wrbUT_hO5auK9U,1370
|
|
5
|
+
almztech/modelos.py,sha256=RhSnKntqqVGalU5aE6DntyssSTtVjPcDBEDsXi2PaJY,3442
|
|
6
|
+
almztech/recursos.py,sha256=6k-WYBBYT7tCntO6d8HYfB33GitqTJ5iQnuWbjY75VM,11681
|
|
7
|
+
almztech/transporte.py,sha256=l4rAa-N5CfJWHVE8ZRnknWbwPCHc8OSo9cskas9Zr20,7525
|
|
8
|
+
almztech/webhook.py,sha256=P1cZjjxqRHiR5g4ErEgBu2pFIdoHwCyxuc322kM-IxQ,2063
|
|
9
|
+
almztech_sdk-0.2.0b1.dist-info/licenses/LICENSE,sha256=uqMmLTm1nVGoPA4sX-SuPVAlHqOIAOodFCmgTKlrYyM,1080
|
|
10
|
+
almztech_sdk-0.2.0b1.dist-info/METADATA,sha256=Ji4EEy86vucIs80RPYEjinC4kQ7QLlRYKwuJFKEXG3s,4754
|
|
11
|
+
almztech_sdk-0.2.0b1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
almztech_sdk-0.2.0b1.dist-info/top_level.txt,sha256=GvWAgq0KzS6oMP9Dsi2ijW9_HWVgzo0Cl4Zw8KkjiRo,9
|
|
13
|
+
almztech_sdk-0.2.0b1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ALMZTECH SOFTWARES LTDA
|
|
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 @@
|
|
|
1
|
+
almztech
|