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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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