foxnfe 1.3.0__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.
foxnfe/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """SDK oficial FOX NF-e para Python."""
2
+
3
+ from .client import Client
4
+ from .exceptions import ApiException, AuthException, FoxNfeException
5
+ from .types import (
6
+ LoginResponse,
7
+ McpTool,
8
+ NfeEmitRequest,
9
+ NfeResource,
10
+ NfseEmitRequest,
11
+ NfseResource,
12
+ )
13
+ from .webhook import Webhook
14
+ from .reference import Reference
15
+ from .documents import Documents
16
+ from .events import NfeEvents
17
+ from .distribuicao import Distribuicao
18
+ from .rtc import Rtc
19
+ from .support import Support
20
+
21
+ __version__ = "1.3.0"
22
+ __all__ = [
23
+ "Client",
24
+ "FoxNfeException",
25
+ "ApiException",
26
+ "AuthException",
27
+ "NfeEmitRequest",
28
+ "NfeResource",
29
+ "NfseEmitRequest",
30
+ "NfseResource",
31
+ "McpTool",
32
+ "LoginResponse",
33
+ "Webhook",
34
+ "Reference",
35
+ "Documents",
36
+ "NfeEvents",
37
+ "Distribuicao",
38
+ "Rtc",
39
+ "Support",
40
+ ]
foxnfe/client.py ADDED
@@ -0,0 +1,172 @@
1
+ """Cliente HTTP base do SDK FOX NF-e."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any, Optional
5
+ import requests
6
+ from requests import Response, Session
7
+
8
+ from .exceptions import ApiException, AuthException, FoxNfeException
9
+ from .types import LoginResponse
10
+
11
+
12
+ class Client:
13
+ """Cliente principal do SDK FOX NF-e."""
14
+
15
+ DEFAULT_BASE_URL = "https://foxnfe.centralfox.online/api/v1"
16
+
17
+ def __init__(
18
+ self,
19
+ tenant_slug: str,
20
+ token: Optional[str] = None,
21
+ base_url: str = DEFAULT_BASE_URL,
22
+ timeout: float = 30.0,
23
+ ) -> None:
24
+ self.tenant_slug = tenant_slug
25
+ self._token = token
26
+ self._base_url = base_url.rstrip("/")
27
+ self._timeout = timeout
28
+ self._session = Session()
29
+ self._session.headers.update({
30
+ "Accept": "application/json",
31
+ "Content-Type": "application/json",
32
+ "X-Tenant-ID": tenant_slug,
33
+ })
34
+
35
+ def login(self, email: str, password: str) -> LoginResponse:
36
+ """Autentica e armazena o token internamente."""
37
+ data = self.post("auth/login", {"email": email, "password": password})
38
+ token = data.get("token")
39
+ if not token:
40
+ raise AuthException("Token não retornado pela API.")
41
+ self._token = token
42
+ return LoginResponse.from_dict(data)
43
+
44
+ def with_token(self, token: str) -> "Client":
45
+ """Retorna uma nova instância com token definido."""
46
+ client = Client(
47
+ tenant_slug=self.tenant_slug,
48
+ token=token,
49
+ base_url=self._base_url,
50
+ timeout=self._timeout,
51
+ )
52
+ return client
53
+
54
+ def logout(self) -> None:
55
+ """Invalida o token na API e limpa internamente."""
56
+ self.post("auth/logout")
57
+ self._token = None
58
+
59
+ def me(self) -> dict[str, Any]:
60
+ return self.get("auth/me")
61
+
62
+ # ── Módulos ─────────────────────────────────────────────────────────────
63
+
64
+ @property
65
+ def nfe(self) -> "Nfe": # type: ignore[name-defined]
66
+ from .nfe import Nfe
67
+ return Nfe(self)
68
+
69
+ @property
70
+ def nfse(self) -> "Nfse": # type: ignore[name-defined]
71
+ from .nfse import Nfse
72
+ return Nfse(self)
73
+
74
+ @property
75
+ def mcp(self) -> "Mcp": # type: ignore[name-defined]
76
+ from .mcp import Mcp
77
+ return Mcp(self)
78
+
79
+ @property
80
+ def webhook(self) -> "Webhook": # type: ignore[name-defined]
81
+ from .webhook import Webhook
82
+ return Webhook(self)
83
+
84
+ @property
85
+ def reference(self) -> "Reference": # type: ignore[name-defined]
86
+ from .reference import Reference
87
+ return Reference(self)
88
+
89
+ @property
90
+ def documents(self) -> "Documents": # type: ignore[name-defined]
91
+ from .documents import Documents
92
+ return Documents(self)
93
+
94
+ @property
95
+ def nfe_events(self) -> "NfeEvents": # type: ignore[name-defined]
96
+ from .events import NfeEvents
97
+ return NfeEvents(self)
98
+
99
+ @property
100
+ def distribuicao(self) -> "Distribuicao": # type: ignore[name-defined]
101
+ from .distribuicao import Distribuicao
102
+ return Distribuicao(self)
103
+
104
+ @property
105
+ def rtc(self) -> "Rtc": # type: ignore[name-defined]
106
+ from .rtc import Rtc
107
+ return Rtc(self)
108
+
109
+ @property
110
+ def support(self) -> "Support": # type: ignore[name-defined]
111
+ from .support import Support
112
+ return Support(self)
113
+
114
+ # ── HTTP helpers ─────────────────────────────────────────────────────────
115
+
116
+ def get(self, path: str, params: Optional[dict] = None) -> dict[str, Any]:
117
+ return self._request("GET", path, params=params)
118
+
119
+ def post(self, path: str, body: Optional[dict] = None) -> dict[str, Any]:
120
+ return self._request("POST", path, json=body)
121
+
122
+ def put(self, path: str, body: Optional[dict] = None) -> dict[str, Any]:
123
+ return self._request("PUT", path, json=body)
124
+
125
+ def delete(self, path: str) -> dict[str, Any]:
126
+ return self._request("DELETE", path)
127
+
128
+ def upload(self, path: str, field: str, filename: str, content: bytes, fields: Optional[dict] = None) -> dict[str, Any]:
129
+ """Envio multipart (upload de XML); o Content-Type JSON da sessão é removido."""
130
+ return self._request("POST", path, files={field: (filename, content, "application/xml")}, data=fields or {})
131
+
132
+ def download(self, path: str) -> bytes:
133
+ """Retorna bytes do conteúdo (XML, PDF)."""
134
+ resp = self._raw_request("GET", path)
135
+ if not resp.ok:
136
+ raise ApiException.from_response(resp.status_code, resp.json() if resp.content else {})
137
+ return resp.content
138
+
139
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
140
+ resp = self._raw_request(method, path, **kwargs)
141
+ try:
142
+ body = resp.json()
143
+ except Exception as exc:
144
+ raise FoxNfeException(f"Resposta JSON inválida: {exc}") from exc
145
+
146
+ if not resp.ok:
147
+ if resp.status_code in (401, 403):
148
+ raise AuthException(body.get("message", "Não autorizado."))
149
+ raise ApiException.from_response(resp.status_code, body)
150
+
151
+ return body # type: ignore[return-value]
152
+
153
+ def _raw_request(self, method: str, path: str, **kwargs: Any) -> Response:
154
+ url = f"{self._base_url}/{path.lstrip('/')}"
155
+ headers: dict[str, Any] = {}
156
+ if self._token:
157
+ headers["Authorization"] = f"Bearer {self._token}"
158
+ if "files" in kwargs:
159
+ headers["Content-Type"] = None # requests define o boundary multipart
160
+
161
+ try:
162
+ return self._session.request(
163
+ method,
164
+ url,
165
+ headers=headers,
166
+ timeout=self._timeout,
167
+ **kwargs,
168
+ )
169
+ except requests.exceptions.Timeout as exc:
170
+ raise FoxNfeException(f"Timeout após {self._timeout}s") from exc
171
+ except requests.exceptions.ConnectionError as exc:
172
+ raise FoxNfeException(f"Erro de conexão: {exc}") from exc
foxnfe/distribuicao.py ADDED
@@ -0,0 +1,73 @@
1
+ """NF-e recebidas: distribuição DF-e, cursor, captura automática e manifestação do destinatário."""
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ from typing import TYPE_CHECKING, Any, Optional
6
+
7
+ if TYPE_CHECKING:
8
+ from .client import Client
9
+
10
+ TIPOS_MANIFESTACAO = (210200, 210210, 210220, 210240)
11
+
12
+
13
+ class Distribuicao:
14
+ def __init__(self, client: "Client") -> None:
15
+ self._client = client
16
+
17
+ def list(self, **params: Any) -> dict[str, Any]:
18
+ return self._client.get("nfe-distribuicao", params=_clean(params))
19
+
20
+ def cursor(self) -> dict[str, Any]:
21
+ return self._client.get("nfe-distribuicao/cursor")
22
+
23
+ def optins(self) -> dict[str, Any]:
24
+ return self._client.get("nfe-distribuicao/captura-automatica")
25
+
26
+ def enable_optin(self, certificate_id: int, ambiente: int) -> dict[str, Any]:
27
+ _positive(certificate_id, "certificate_id")
28
+ return self._client.put("nfe-distribuicao/captura-automatica", {"certificate_id": certificate_id, "ambiente": ambiente})
29
+
30
+ def disable_optin(self, optin_id: int) -> dict[str, Any]:
31
+ _positive(optin_id, "optin_id")
32
+ return self._client.delete(f"nfe-distribuicao/captura-automatica/{optin_id}")
33
+
34
+ def capturar(self, certificate_id: Optional[int] = None, ambiente: Optional[int] = None) -> dict[str, Any]:
35
+ return self._client.post("nfe-distribuicao/capturar", _clean({"certificate_id": certificate_id, "ambiente": ambiente}))
36
+
37
+ def manifestacoes(self, **params: Any) -> dict[str, Any]:
38
+ return self._client.get("nfe-distribuicao/manifestacoes", params=_clean(params))
39
+
40
+ def manifestacao(self, manifestacao_id: int) -> dict[str, Any]:
41
+ _positive(manifestacao_id, "manifestacao_id")
42
+ return self._client.get(f"nfe-distribuicao/manifestacoes/{manifestacao_id}")
43
+
44
+ def solicitar_manifestacao(self, chave_acesso: str, tipo_evento: int, **extra: Any) -> dict[str, Any]:
45
+ if not re.fullmatch(r"[0-9]{6}[0-9A-Z]{12}[0-9]{26}", chave_acesso or ""):
46
+ raise ValueError("chave_acesso inválida (44 posições).")
47
+ if tipo_evento not in TIPOS_MANIFESTACAO:
48
+ raise ValueError("tipo_evento inválido.")
49
+ return self._client.post("nfe-distribuicao/manifestacoes", {"chave_acesso": chave_acesso, "tipo_evento": tipo_evento, **_clean(extra)})
50
+
51
+ def aprovar_manifestacao(self, manifestacao_id: int, payload_hash: str) -> dict[str, Any]:
52
+ """Aprovação vinculada ao payload_hash devolvido na solicitação."""
53
+ _positive(manifestacao_id, "manifestacao_id")
54
+ if not re.fullmatch(r"[0-9a-f]{64}", payload_hash or ""):
55
+ raise ValueError("payload_hash deve ser sha256 hex minúsculo.")
56
+ return self._client.post(f"nfe-distribuicao/manifestacoes/{manifestacao_id}/aprovar", {"payload_hash": payload_hash})
57
+
58
+ def transmitir_manifestacao(self, manifestacao_id: int) -> dict[str, Any]:
59
+ _positive(manifestacao_id, "manifestacao_id")
60
+ return self._client.post(f"nfe-distribuicao/manifestacoes/{manifestacao_id}/transmitir")
61
+
62
+ def reconciliar_manifestacao(self, manifestacao_id: int) -> dict[str, Any]:
63
+ _positive(manifestacao_id, "manifestacao_id")
64
+ return self._client.post(f"nfe-distribuicao/manifestacoes/{manifestacao_id}/reconciliar")
65
+
66
+
67
+ def _clean(d: dict[str, Any]) -> dict[str, Any]:
68
+ return {k: v for k, v in d.items() if v is not None}
69
+
70
+
71
+ def _positive(v: int, label: str) -> None:
72
+ if not isinstance(v, int) or v < 1:
73
+ raise ValueError(f"{label} deve ser inteiro positivo.")
foxnfe/documents.py ADDED
@@ -0,0 +1,58 @@
1
+ """Central de Documentos: consulta unificada, detalhe, importação de XML com prévia e exportação ZIP."""
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ from typing import TYPE_CHECKING, Any, Optional
6
+ from urllib.parse import quote
7
+
8
+ if TYPE_CHECKING:
9
+ from .client import Client
10
+
11
+ _UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
12
+
13
+
14
+ class Documents:
15
+ def __init__(self, client: "Client") -> None:
16
+ self._client = client
17
+
18
+ def list(self, **params: Any) -> dict[str, Any]:
19
+ return self._client.get("documents", params={k: v for k, v in params.items() if v is not None})
20
+
21
+ def show(self, source: str, doc_id: int) -> dict[str, Any]:
22
+ if not re.fullmatch(r"[a-z_]+", source):
23
+ raise ValueError("source inválido.")
24
+ _positive(doc_id, "doc_id")
25
+ return self._client.get(f"documents/{source}/{doc_id}")
26
+
27
+ def import_preview(self, xml: bytes, filename: str = "nota.xml") -> dict[str, Any]:
28
+ """Valida e vincula SEM persistir; devolve integrity.raw_sha256 para o commit."""
29
+ return self._client.upload("documents/imports/preview", "file", filename, xml)
30
+
31
+ def import_xml(self, xml: bytes, preview_sha256: Optional[str] = None, filename: str = "nota.xml") -> dict[str, Any]:
32
+ fields: dict[str, str] = {}
33
+ if preview_sha256 is not None:
34
+ if not re.fullmatch(r"[0-9a-f]{64}", preview_sha256):
35
+ raise ValueError("preview_sha256 deve ser sha256 hex minúsculo.")
36
+ fields["preview_sha256"] = preview_sha256
37
+ return self._client.upload("documents/imports", "file", filename, xml, fields)
38
+
39
+ def export_request(self, **filters: Any) -> dict[str, Any]:
40
+ return self._client.post("documents/exports", {k: v for k, v in filters.items() if v is not None})
41
+
42
+ def export_status(self, request_id: str) -> dict[str, Any]:
43
+ _uuid(request_id)
44
+ return self._client.get(f"documents/exports/{quote(request_id, safe='')}")
45
+
46
+ def export_download(self, request_id: str) -> bytes:
47
+ _uuid(request_id)
48
+ return self._client.download(f"documents/exports/{quote(request_id, safe='')}/download")
49
+
50
+
51
+ def _positive(v: int, label: str) -> None:
52
+ if not isinstance(v, int) or v < 1:
53
+ raise ValueError(f"{label} deve ser inteiro positivo.")
54
+
55
+
56
+ def _uuid(v: str) -> None:
57
+ if not _UUID.fullmatch(v or ""):
58
+ raise ValueError("request_id deve ser um UUID válido.")
foxnfe/events.py ADDED
@@ -0,0 +1,115 @@
1
+ """Eventos pós-emissão da NF-e: Carta de Correção Eletrônica (assíncrona e durável)."""
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ from typing import TYPE_CHECKING, Any, Optional
6
+
7
+ if TYPE_CHECKING:
8
+ from .client import Client
9
+
10
+
11
+ class NfeEvents:
12
+ def __init__(self, client: "Client") -> None:
13
+ self._client = client
14
+
15
+ def cce_list(self, invoice_id: int) -> dict[str, Any]:
16
+ _positive(invoice_id)
17
+ return self._client.get(f"nfe/{invoice_id}/cce")
18
+
19
+ def cce_create(self, invoice_id: int, correcao: str, sequencial: Optional[int] = None) -> dict[str, Any]:
20
+ """Correção de 15 a 1000 caracteres; sequencial (1..20) opcional — o servidor aloca sob lock."""
21
+ _positive(invoice_id)
22
+ if not 15 <= len(correcao.strip()) <= 1000:
23
+ raise ValueError("correcao deve ter entre 15 e 1000 caracteres.")
24
+ body: dict[str, Any] = {"correcao": correcao}
25
+ if sequencial is not None:
26
+ if not isinstance(sequencial, int) or not 1 <= sequencial <= 20:
27
+ raise ValueError("sequencial deve ser inteiro entre 1 e 20.")
28
+ body["sequencial"] = sequencial
29
+ return self._client.post(f"nfe/{invoice_id}/cce", body)
30
+
31
+
32
+ # ── 1.3.0 — outros eventos da NF-e própria (L17) ─────────────────────────
33
+
34
+ def ator_interessado_list(self, invoice_id: int) -> dict[str, Any]:
35
+ _positive(invoice_id)
36
+ return self._client.get(f"nfe/{invoice_id}/ator-interessado")
37
+
38
+ def ator_interessado(self, invoice_id: int, documento: str, **extra: Any) -> dict[str, Any]:
39
+ """Ator interessado (110150). documento = CPF (11) ou CNPJ (14) só dígitos; extra: tp_autor, tp_autorizacao, sequencial."""
40
+ _positive(invoice_id)
41
+ if not re.fullmatch(r"\d{11}|\d{14}", documento):
42
+ raise ValueError("documento deve ter 11 (CPF) ou 14 (CNPJ) dígitos.")
43
+ return self._client.post(f"nfe/{invoice_id}/ator-interessado", {"documento": documento, **extra})
44
+
45
+ def insucesso_entrega_list(self, invoice_id: int) -> dict[str, Any]:
46
+ _positive(invoice_id)
47
+ return self._client.get(f"nfe/{invoice_id}/insucesso-entrega")
48
+
49
+ def insucesso_entrega(self, invoice_id: int, dh_tentativa: str, tp_motivo: int, justificativa: Optional[str] = None, **extra: Any) -> dict[str, Any]:
50
+ """Insucesso na entrega (110192). tp_motivo 1..4; 4 exige justificativa (15..250). A imagem não é persistida."""
51
+ _positive(invoice_id)
52
+ if tp_motivo not in (1, 2, 3, 4):
53
+ raise ValueError("tp_motivo deve ser 1, 2, 3 ou 4.")
54
+ if tp_motivo == 4 and not 15 <= len((justificativa or "").strip()) <= 250:
55
+ raise ValueError("justificativa (15..250) é obrigatória quando tp_motivo=4.")
56
+ body: dict[str, Any] = {"dh_tentativa": dh_tentativa, "tp_motivo": tp_motivo, **extra}
57
+ if justificativa is not None:
58
+ body["justificativa"] = justificativa
59
+ return self._client.post(f"nfe/{invoice_id}/insucesso-entrega", body)
60
+
61
+ def cancelar_insucesso_entrega(self, invoice_id: int, evento_id: int) -> dict[str, Any]:
62
+ _positive(invoice_id); _positive(evento_id)
63
+ return self._client.post(f"nfe/{invoice_id}/insucesso-entrega/{evento_id}/cancelar")
64
+
65
+ def inutilizacoes(self) -> dict[str, Any]:
66
+ return self._client.get("nfe/inutilizacoes")
67
+
68
+ def inutilizacao(self, inutilizacao_id: int) -> dict[str, Any]:
69
+ _positive(inutilizacao_id)
70
+ return self._client.get(f"nfe/inutilizacoes/{inutilizacao_id}")
71
+
72
+ def inutilizar(self, serie: int, numero_inicial: int, numero_final: int, justificativa: str, **extra: Any) -> dict[str, Any]:
73
+ """Inutiliza uma faixa de numeração (assíncrono e durável); extra: modelo (55|65), ano."""
74
+ if numero_inicial < 1 or numero_final < numero_inicial:
75
+ raise ValueError("numero_final deve ser >= numero_inicial >= 1.")
76
+ if not 15 <= len(justificativa.strip()) <= 255:
77
+ raise ValueError("justificativa deve ter entre 15 e 255 caracteres.")
78
+ return self._client.post("nfe/inutilizacoes", {"serie": serie, "numero_inicial": numero_inicial, "numero_final": numero_final, "justificativa": justificativa, **extra})
79
+
80
+ # ── 1.3.0 — eventos por contrato oficial (L18) ───────────────────────────
81
+
82
+ def contratos(self) -> dict[str, Any]:
83
+ return self._client.get("nfe/eventos/contratos")
84
+
85
+ def eventos(self, invoice_id: int) -> dict[str, Any]:
86
+ _positive(invoice_id)
87
+ return self._client.get(f"nfe/{invoice_id}/eventos")
88
+
89
+ def registrar_evento(self, invoice_id: int, tipo: str, payload: Optional[dict[str, Any]] = None, sequencial: Optional[int] = None) -> dict[str, Any]:
90
+ _positive(invoice_id); _tipo(tipo)
91
+ body: dict[str, Any] = {"tipo": tipo, **(payload or {})}
92
+ if sequencial is not None:
93
+ body["sequencial"] = sequencial
94
+ return self._client.post(f"nfe/{invoice_id}/eventos", body)
95
+
96
+ def eventos_recebida(self, distribuicao_id: int) -> dict[str, Any]:
97
+ _positive(distribuicao_id)
98
+ return self._client.get(f"nfe/recebidas/{distribuicao_id}/eventos")
99
+
100
+ def registrar_evento_recebida(self, distribuicao_id: int, tipo: str, payload: Optional[dict[str, Any]] = None, sequencial: Optional[int] = None) -> dict[str, Any]:
101
+ _positive(distribuicao_id); _tipo(tipo)
102
+ body: dict[str, Any] = {"tipo": tipo, **(payload or {})}
103
+ if sequencial is not None:
104
+ body["sequencial"] = sequencial
105
+ return self._client.post(f"nfe/recebidas/{distribuicao_id}/eventos", body)
106
+
107
+
108
+ def _tipo(tipo: str) -> None:
109
+ if not re.fullmatch(r"[a-z0-9_]{3,60}", tipo):
110
+ raise ValueError("tipo deve ser um identificador do catálogo (GET nfe/eventos/contratos).")
111
+
112
+
113
+ def _positive(v: int) -> None:
114
+ if not isinstance(v, int) or v < 1:
115
+ raise ValueError("invoice_id deve ser inteiro positivo.")
foxnfe/exceptions.py ADDED
@@ -0,0 +1,30 @@
1
+ """Exceções do SDK FOX NF-e."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any, Optional
5
+
6
+
7
+ class FoxNfeException(Exception):
8
+ """Exceção base do SDK."""
9
+
10
+ def __init__(self, message: str, context: Optional[dict[str, Any]] = None) -> None:
11
+ super().__init__(message)
12
+ self.context = context
13
+
14
+
15
+ class ApiException(FoxNfeException):
16
+ """Erro retornado pela API FOX NF-e."""
17
+
18
+ def __init__(self, status_code: int, message: str, response_body: Optional[dict] = None) -> None:
19
+ super().__init__(message, response_body)
20
+ self.status_code = status_code
21
+ self.response_body = response_body
22
+
23
+ @classmethod
24
+ def from_response(cls, status_code: int, body: dict) -> "ApiException":
25
+ message = body.get("message") or body.get("error") or f"HTTP {status_code}"
26
+ return cls(status_code, message, body)
27
+
28
+
29
+ class AuthException(FoxNfeException):
30
+ """Erro de autenticação."""
foxnfe/mcp.py ADDED
@@ -0,0 +1,45 @@
1
+ """Módulo MCP do SDK FOX NF-e."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+
6
+ from .types import McpTool
7
+
8
+
9
+ class Mcp:
10
+ MCP_PATH = "mcp"
11
+
12
+ def __init__(self, client: Any) -> None:
13
+ self._client = client
14
+
15
+ def list_tools(self) -> list[McpTool]:
16
+ """Lista todas as tools MCP disponíveis."""
17
+ resp = self._client.post(self.MCP_PATH, {
18
+ "jsonrpc": "2.0",
19
+ "id": 1,
20
+ "method": "tools/list",
21
+ "params": {},
22
+ })
23
+ return [McpTool.from_dict(t) for t in resp.get("result", {}).get("tools", [])]
24
+
25
+ def call_tool(self, tool: str, input: dict[str, Any] | None = None) -> dict[str, Any]:
26
+ """Chama uma tool MCP pelo nome."""
27
+ return self._client.post(self.MCP_PATH, {
28
+ "jsonrpc": "2.0",
29
+ "id": 2,
30
+ "method": "tools/call",
31
+ "params": {"name": tool, "arguments": input or {}},
32
+ })
33
+
34
+ def initialize(self) -> dict[str, Any]:
35
+ """Retorna informações do servidor MCP."""
36
+ return self._client.post(self.MCP_PATH, {
37
+ "jsonrpc": "2.0",
38
+ "id": 0,
39
+ "method": "initialize",
40
+ "params": {
41
+ "protocolVersion": "2024-11-05",
42
+ "clientInfo": {"name": "foxnfe-python-sdk", "version": "1.0.0"},
43
+ "capabilities": {},
44
+ },
45
+ })
foxnfe/nfe.py ADDED
@@ -0,0 +1,93 @@
1
+ """Módulo NF-e do SDK FOX NF-e."""
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ from .exceptions import FoxNfeException
9
+ from .types import NfeEmitRequest, NfeResource
10
+
11
+
12
+ class Nfe:
13
+ def __init__(self, client: Any) -> None:
14
+ self._client = client
15
+
16
+ def emit(self, payload: NfeEmitRequest) -> dict[str, Any]:
17
+ """Emite uma NF-e. Retorna imediatamente com status 'pending'."""
18
+ return self._client.post("nfe/emit", payload.to_dict())
19
+
20
+ def get(self, nfe_id: int) -> NfeResource:
21
+ """Consulta status e dados de uma NF-e."""
22
+ data = self._client.get(f"nfe/{nfe_id}")
23
+ return NfeResource.from_dict(data)
24
+
25
+ def xml(self, nfe_id: int) -> bytes:
26
+ """Baixa o XML assinado da NF-e autorizada."""
27
+ return self._client.download(f"nfe/{nfe_id}/xml")
28
+
29
+ def pdf(self, nfe_id: int) -> bytes:
30
+ """Baixa o DANFE em PDF."""
31
+ return self._client.download(f"nfe/{nfe_id}/pdf")
32
+
33
+ def cancel(self, nfe_id: int, justificativa: str) -> NfeResource:
34
+ """Cancela uma NF-e autorizada. Justificativa mínima: 15 chars."""
35
+ data = self._client.post(f"nfe/{nfe_id}/cancel", {"justificativa": justificativa})
36
+ return NfeResource.from_dict(data)
37
+
38
+ # ── 1.3.0 — rejeições explicadas e homologação por modelo (L20) ──────────
39
+
40
+ def rejeicoes(self) -> dict[str, Any]:
41
+ """Catálogo de rejeições SEFAZ: categoria, ação e dica por cStat."""
42
+ return self._client.get("nfe/rejeicoes")
43
+
44
+ def rejeicao(self, cstat: int | str) -> dict[str, Any]:
45
+ c = str(cstat)
46
+ if not re.fullmatch(r"\d{3}", c):
47
+ raise ValueError("cstat deve ter 3 dígitos.")
48
+ return self._client.get(f"nfe/rejeicoes/{c}")
49
+
50
+ def homologacao(self) -> dict[str, Any]:
51
+ """Matriz de homologação (modelo/UF/cenário) e últimas corridas."""
52
+ return self._client.get("nfe/homologacao")
53
+
54
+ def homologacao_run(self, modelo: int, cenario: Optional[str] = None, mode: str = "simulated") -> dict[str, Any]:
55
+ """simulated (padrão): amostras XML/PDF assinadas sem transmitir; sefaz: só com autorização no servidor (409)."""
56
+ if modelo not in (55, 65):
57
+ raise ValueError("modelo deve ser 55 ou 65.")
58
+ if mode not in ("simulated", "sefaz"):
59
+ raise ValueError("mode deve ser 'simulated' ou 'sefaz'.")
60
+ body: dict[str, Any] = {"modelo": modelo, "mode": mode}
61
+ if cenario:
62
+ body["cenario"] = cenario
63
+ return self._client.post("nfe/homologacao/run", body)
64
+
65
+ def homologacao_xml(self, run_id: int) -> bytes:
66
+ _positive(run_id)
67
+ return self._client.download(f"nfe/homologacao/{run_id}/xml")
68
+
69
+ def homologacao_pdf(self, run_id: int) -> bytes:
70
+ _positive(run_id)
71
+ return self._client.download(f"nfe/homologacao/{run_id}/pdf")
72
+
73
+ def wait_for_authorization(
74
+ self,
75
+ nfe_id: int,
76
+ max_wait_s: float = 120.0,
77
+ poll_s: float = 3.0,
78
+ ) -> NfeResource:
79
+ """Aguarda até que a NF-e saia do status 'pending'."""
80
+ deadline = time.monotonic() + max_wait_s
81
+
82
+ while time.monotonic() < deadline:
83
+ nfe = self.get(nfe_id)
84
+ if nfe.status != "pending":
85
+ return nfe
86
+ time.sleep(poll_s)
87
+
88
+ raise FoxNfeException(f"NF-e {nfe_id} ainda pendente após {max_wait_s}s.")
89
+
90
+
91
+ def _positive(v: int) -> None:
92
+ if not isinstance(v, int) or v < 1:
93
+ raise ValueError("id deve ser inteiro positivo.")