treasy-mcp 0.0.0.dev0__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.
- treasy_mcp/__init__.py +24 -0
- treasy_mcp/__main__.py +89 -0
- treasy_mcp/_versao.py +7 -0
- treasy_mcp/arquivos.py +471 -0
- treasy_mcp/auditoria.py +134 -0
- treasy_mcp/cache.py +73 -0
- treasy_mcp/catalogo_vivo.py +457 -0
- treasy_mcp/client.py +3831 -0
- treasy_mcp/config.py +61 -0
- treasy_mcp/constants.py +27 -0
- treasy_mcp/coverage.py +135 -0
- treasy_mcp/credenciais.py +297 -0
- treasy_mcp/data/catalog.json +139510 -0
- treasy_mcp/doctor.py +186 -0
- treasy_mcp/efeito.py +124 -0
- treasy_mcp/escrita.py +259 -0
- treasy_mcp/exportacao.py +583 -0
- treasy_mcp/formatters.py +153 -0
- treasy_mcp/gateway.py +700 -0
- treasy_mcp/helpers.py +216 -0
- treasy_mcp/instalacao.py +91 -0
- treasy_mcp/login_navegador.py +1022 -0
- treasy_mcp/modo.py +357 -0
- treasy_mcp/paridade.py +473 -0
- treasy_mcp/server.py +1825 -0
- treasy_mcp/sessao_auth.py +482 -0
- treasy_mcp/spec_catalog.py +383 -0
- treasy_mcp/tenant_session.py +93 -0
- treasy_mcp/tools/__init__.py +1 -0
- treasy_mcp/tools/analise/__init__.py +1 -0
- treasy_mcp/tools/analise/treasy_analise_alavancas.py +151 -0
- treasy_mcp/tools/analise/treasy_comparativo_periodos.py +119 -0
- treasy_mcp/tools/analise/treasy_projecao_fluxo_caixa.py +159 -0
- treasy_mcp/tools/controle/__init__.py +1 -0
- treasy_mcp/tools/controle/treasy_aging_recebiveis.py +157 -0
- treasy_mcp/tools/controle/treasy_detectar_duplicatas.py +155 -0
- treasy_mcp/tools/planejamento/__init__.py +1 -0
- treasy_mcp/tools/planejamento/treasy_desvio_orcado_realizado.py +133 -0
- treasy_mcp-0.0.0.dev0.dist-info/METADATA +98 -0
- treasy_mcp-0.0.0.dev0.dist-info/RECORD +44 -0
- treasy_mcp-0.0.0.dev0.dist-info/WHEEL +5 -0
- treasy_mcp-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- treasy_mcp-0.0.0.dev0.dist-info/licenses/LICENSE +28 -0
- treasy_mcp-0.0.0.dev0.dist-info/top_level.txt +1 -0
treasy_mcp/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# treasy_mcp — MCP Server para a API Treasy
|
|
2
|
+
import os as _os
|
|
3
|
+
|
|
4
|
+
from treasy_mcp._versao import __version__ # noqa: F401 (fonte única, esforço 003968)
|
|
5
|
+
from treasy_mcp.instalacao import aplicar_padroes_do_cliente as _aplicar_padroes_do_cliente
|
|
6
|
+
|
|
7
|
+
# Pacote instalado (wheel) e sem TREASY_MCP_MODO: o cliente entra no modo claude-code em
|
|
8
|
+
# produção, somente leitura (003968, D5). No checkout (Copilot, Docker) isto não faz nada.
|
|
9
|
+
_aplicar_padroes_do_cliente()
|
|
10
|
+
|
|
11
|
+
# Modo claude-code (002675): valida as variáveis e carrega os segredos do client
|
|
12
|
+
# ambassador ANTES de qualquer import do config.py, que lê os.environ na importação.
|
|
13
|
+
# Sem TREASY_MCP_MODO (o caminho do Copilot), nada aqui roda.
|
|
14
|
+
if _os.environ.get("TREASY_MCP_MODO", "").strip():
|
|
15
|
+
import warnings as _warnings
|
|
16
|
+
|
|
17
|
+
_warnings.filterwarnings(
|
|
18
|
+
"ignore",
|
|
19
|
+
message=r"'treasy_mcp\.(modo|credenciais)' found in sys\.modules",
|
|
20
|
+
category=RuntimeWarning,
|
|
21
|
+
)
|
|
22
|
+
from treasy_mcp.modo import preparar as _preparar
|
|
23
|
+
|
|
24
|
+
_preparar()
|
treasy_mcp/__main__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Entry point: ``treasy-mcp`` (ou ``python -m treasy_mcp``).
|
|
2
|
+
|
|
3
|
+
Sem argumento, sobe o servidor MCP por stdio: é o que o Claude Code chama. Com argumento, é a
|
|
4
|
+
linha de comando do cliente (esforço 003968, D6 = subcomando): ``login``, ``status``, ``sair``,
|
|
5
|
+
``doctor`` e ``--version``. O login é o do ``003962`` (``login_navegador``), não uma cópia.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
USO = """uso: treasy-mcp [comando]
|
|
14
|
+
|
|
15
|
+
(sem comando) sobe o servidor MCP (é o que o Claude Code chama)
|
|
16
|
+
login [ambiente] abre o navegador para você entrar no Treasy (padrão: prod)
|
|
17
|
+
status [ambiente] mostra a sessão gravada, nunca o token
|
|
18
|
+
sair [ambiente] apaga a sessão local (padrão: prod)
|
|
19
|
+
doctor diagnóstico da instalação em seis linhas, sem segredo
|
|
20
|
+
--version mostra a versão
|
|
21
|
+
|
|
22
|
+
ambiente: prod, qa ou sandbox. Instruções completas: INSTALAR.md.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
COMANDOS_DE_LOGIN = ("login", "status", "sair")
|
|
26
|
+
COM_AMBIENTE_OBRIGATORIO = ("login", "sair")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ambiente_padrao() -> str:
|
|
30
|
+
return (os.environ.get("TREASY_AMBIENTE") or "").strip() or "prod"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _argumentos_do_login(comando: str, resto: list[str]) -> list[str]:
|
|
34
|
+
"""Põe o ambiente padrão quando a pessoa não disse qual (``treasy-mcp login``)."""
|
|
35
|
+
sem_ambiente = not resto or resto[0].startswith("-")
|
|
36
|
+
if comando in COM_AMBIENTE_OBRIGATORIO and sem_ambiente:
|
|
37
|
+
resto = [_ambiente_padrao(), *resto]
|
|
38
|
+
elif comando == "status" and sem_ambiente and not _em_checkout():
|
|
39
|
+
# Instalado, o ``status`` fala só do ambiente do cliente: qa e sandbox são da casa (003973).
|
|
40
|
+
resto = [_ambiente_padrao(), *resto]
|
|
41
|
+
return [comando, *resto]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _em_checkout() -> bool:
|
|
45
|
+
from treasy_mcp import instalacao
|
|
46
|
+
|
|
47
|
+
return instalacao.em_checkout()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _servidor() -> None: # pragma: no cover - sobe o servidor de verdade, provado na instalação
|
|
51
|
+
import asyncio
|
|
52
|
+
|
|
53
|
+
from treasy_mcp import catalogo_vivo
|
|
54
|
+
from treasy_mcp.server import main as async_main
|
|
55
|
+
|
|
56
|
+
catalogo_vivo.iniciar() # volta na hora; a busca da spec do T30 roda numa thread de fundo
|
|
57
|
+
asyncio.run(async_main())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: list[str] | None = None) -> int:
|
|
61
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
62
|
+
if not args:
|
|
63
|
+
_servidor()
|
|
64
|
+
return 0
|
|
65
|
+
comando, resto = args[0], args[1:]
|
|
66
|
+
if comando in ("--version", "-V", "versao", "versão"):
|
|
67
|
+
from treasy_mcp import __version__
|
|
68
|
+
|
|
69
|
+
print(f"treasy-mcp {__version__}")
|
|
70
|
+
return 0
|
|
71
|
+
if comando in ("-h", "--help", "ajuda"):
|
|
72
|
+
print(USO)
|
|
73
|
+
return 0
|
|
74
|
+
if comando in COMANDOS_DE_LOGIN:
|
|
75
|
+
from treasy_mcp import instalacao, login_navegador
|
|
76
|
+
|
|
77
|
+
if not instalacao.em_checkout(): # instalado, o login ensina o comando do pacote, não o python -m
|
|
78
|
+
login_navegador.PROG = instalacao.PREFIXO_DO_CLIENTE
|
|
79
|
+
return login_navegador.main(_argumentos_do_login(comando, resto))
|
|
80
|
+
if comando == "doctor":
|
|
81
|
+
from treasy_mcp import doctor
|
|
82
|
+
|
|
83
|
+
return doctor.main(resto)
|
|
84
|
+
print(f"comando desconhecido: {comando}\n\n{USO}", file=sys.stderr)
|
|
85
|
+
return 2
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__": # pragma: no cover
|
|
89
|
+
sys.exit(main())
|
treasy_mcp/_versao.py
ADDED
treasy_mcp/arquivos.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Envio e download de arquivo pelo gateway (esforço 003966).
|
|
3
|
+
|
|
4
|
+
Três portas, nesta ordem: o portão de origem (`validar_origem`) recusa o caminho antes de abrir o
|
|
5
|
+
arquivo; a confirmação mostra nome, bytes e sha256 e a reconfirmação recalcula o hash
|
|
6
|
+
(`preparar_envio` mais `_PENDENTES`); o download grava só dentro de
|
|
7
|
+
``<pasta de exportação>/<ambiente>/<schema>/arquivos/`` e devolve caminho, tamanho e sha256,
|
|
8
|
+
nunca o binário.
|
|
9
|
+
|
|
10
|
+
O fio foi medido no sandbox em 2026-11-01 (P0, `04-PROGRESSO.md` do esforço): a parte JSON do
|
|
11
|
+
``/fatos/salvar`` só entra como ``application/json`` (texto dá 415); sem a parte ``comentarios`` o
|
|
12
|
+
servidor dá 500; o upload de anexo de fato exige ``fatoId`` também como parâmetro da requisição; o
|
|
13
|
+
download por ``/{filename}`` devolve binário rotulado ``application/json``; o servidor corta o
|
|
14
|
+
pedido entre 45 e 50 MB.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import itertools
|
|
21
|
+
import json
|
|
22
|
+
import mimetypes
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import unicodedata
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path, PureWindowsPath
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import quote
|
|
30
|
+
|
|
31
|
+
from treasy_mcp import exportacao as _exportacao
|
|
32
|
+
|
|
33
|
+
MB = 1024 * 1024
|
|
34
|
+
TETO_PADRAO_MB = 50 # D3 (CEO, 2026-11-01): 50 MB nos dois sentidos, ajustável por variável
|
|
35
|
+
TETO_JSON_EM_MEMORIA = 4 * MB
|
|
36
|
+
BLOCO = 64 * 1024
|
|
37
|
+
TETO_NOME = 80
|
|
38
|
+
EXTENSOES_PERMITIDAS = frozenset({"pdf", "png", "jpg", "jpeg", "xlsx", "csv", "txt", "docx"}) # D6
|
|
39
|
+
NOMES_RESERVADOS = frozenset(
|
|
40
|
+
{"con", "prn", "aux", "nul"} | {f"com{i}" for i in range(1, 10)} | {f"lpt{i}" for i in range(1, 10)}
|
|
41
|
+
)
|
|
42
|
+
PASTAS_BLOQUEADAS = frozenset({".treasy", ".ssh", ".git", "treasy-exportacoes", ".aws", ".azure", ".gnupg"})
|
|
43
|
+
SUFIXOS_BLOQUEADOS = (".pem", ".key", ".pfx", ".p12")
|
|
44
|
+
INICIOS_EXECUTAVEIS = (b"MZ", b"#!", b"<script")
|
|
45
|
+
EXTENSOES_EXECUTAVEIS = frozenset({
|
|
46
|
+
"exe", "dll", "com", "bat", "cmd", "ps1", "psm1", "vbs", "vbe", "js", "jse", "wsf", "hta", "lnk",
|
|
47
|
+
"msi", "scr", "jar", "sh", "html", "htm", "svg", "reg", "cpl", "pif", "url", "chm", "mht", "mhtml",
|
|
48
|
+
"xhtml", "inf", "msc", "wsh", "appref-ms",
|
|
49
|
+
})
|
|
50
|
+
TRANSPORTES_REMOTOS = frozenset({"http", "streamable-http", "streamable_http"})
|
|
51
|
+
OPERACOES_DE_IMAGEM = ("/empresas/logo/", "/empresas/logo_original/", "/usuarios/foto/", "/usuarios/foto_original/")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ArquivoRecusado(ValueError):
|
|
55
|
+
"""Recusa com motivo legível; nada foi lido nem enviado."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _teto(variavel: str) -> int:
|
|
59
|
+
valor = os.environ.get(variavel, "").strip()
|
|
60
|
+
try:
|
|
61
|
+
mb = float(valor) if valor else TETO_PADRAO_MB
|
|
62
|
+
except ValueError:
|
|
63
|
+
mb = TETO_PADRAO_MB
|
|
64
|
+
return int(mb * MB) if mb > 0 else TETO_PADRAO_MB * MB
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def teto_envio() -> int:
|
|
68
|
+
return _teto("TREASY_ARQUIVO_TETO_ENVIO_MB")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def teto_download() -> int:
|
|
72
|
+
return _teto("TREASY_ARQUIVO_TETO_DOWNLOAD_MB")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Portão de origem ──────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def _mesmo_caminho(a: Path, b: Path) -> bool:
|
|
78
|
+
return os.path.normcase(str(a)) == os.path.normcase(str(b))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _conferir_nomes(caminho: Path) -> None:
|
|
82
|
+
for parte in caminho.parts[1:]:
|
|
83
|
+
limpo = parte.rstrip(" .").lower() or parte.lower()
|
|
84
|
+
if limpo.split(".")[0].strip() in NOMES_RESERVADOS:
|
|
85
|
+
raise ArquivoRecusado(f"'{parte}' é um nome reservado do Windows")
|
|
86
|
+
if limpo in PASTAS_BLOQUEADAS or limpo.startswith(".env"):
|
|
87
|
+
raise ArquivoRecusado(f"arquivos dentro de '{parte}' não podem subir (lista de bloqueio)")
|
|
88
|
+
if caminho.name.rstrip(" .").lower().endswith(SUFIXOS_BLOQUEADOS):
|
|
89
|
+
raise ArquivoRecusado("chave ou certificado não pode subir (lista de bloqueio)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validar_origem(caminho: Any) -> Path:
|
|
93
|
+
"""Devolve o caminho real de um arquivo que pode subir, ou levanta `ArquivoRecusado`.
|
|
94
|
+
|
|
95
|
+
Nada aqui abre o arquivo: o portão vem antes da leitura e antes da confirmação.
|
|
96
|
+
"""
|
|
97
|
+
if not isinstance(caminho, str) or not caminho.strip():
|
|
98
|
+
raise ArquivoRecusado("o caminho do arquivo veio vazio ou não é texto")
|
|
99
|
+
texto = caminho.strip()
|
|
100
|
+
if any(unicodedata.category(c) == "Cc" for c in texto):
|
|
101
|
+
raise ArquivoRecusado("o caminho tem caractere de controle")
|
|
102
|
+
if texto.startswith("~"):
|
|
103
|
+
raise ArquivoRecusado("passe o caminho completo do arquivo (o '~' não é expandido)")
|
|
104
|
+
if texto.startswith(("\\\\", "//")):
|
|
105
|
+
raise ArquivoRecusado("caminho de rede (UNC) não é aceito; copie o arquivo para o disco local")
|
|
106
|
+
janela = PureWindowsPath(texto)
|
|
107
|
+
resto = texto[len(janela.drive):] if janela.drive else texto
|
|
108
|
+
if ":" in resto:
|
|
109
|
+
raise ArquivoRecusado("o caminho tem ':' fora da letra do disco (fluxo alternativo do Windows)")
|
|
110
|
+
normal = Path(texto.replace("\\", "/")) if os.sep == "/" else Path(texto)
|
|
111
|
+
if not normal.is_absolute():
|
|
112
|
+
raise ArquivoRecusado("passe o caminho completo do arquivo (caminho relativo não é aceito)")
|
|
113
|
+
_conferir_nomes(normal)
|
|
114
|
+
if normal.is_symlink():
|
|
115
|
+
raise ArquivoRecusado("o arquivo é um link simbólico; passe o arquivo de verdade")
|
|
116
|
+
try:
|
|
117
|
+
real = normal.resolve(strict=True)
|
|
118
|
+
except (FileNotFoundError, OSError):
|
|
119
|
+
raise ArquivoRecusado("o arquivo não existe nesse caminho") from None
|
|
120
|
+
if not _mesmo_caminho(real, Path(os.path.abspath(normal))):
|
|
121
|
+
raise ArquivoRecusado("o caminho passa por link ou junção; passe o caminho real do arquivo")
|
|
122
|
+
if not real.is_file():
|
|
123
|
+
raise ArquivoRecusado("o caminho não é um arquivo comum")
|
|
124
|
+
_conferir_nomes(real) # gauntlet: ".ssh." vira ".ssh" no Windows; confere o nome real também
|
|
125
|
+
if any((pai / ".git").exists() for pai in real.parents):
|
|
126
|
+
raise ArquivoRecusado("o arquivo está dentro de um repositório git (lista de bloqueio)")
|
|
127
|
+
extensao = real.suffix.lower().lstrip(".")
|
|
128
|
+
if extensao not in EXTENSOES_PERMITIDAS:
|
|
129
|
+
raise ArquivoRecusado(
|
|
130
|
+
f"tipo '.{extensao}' não pode subir; aceitos: {', '.join(sorted(EXTENSOES_PERMITIDAS))}"
|
|
131
|
+
)
|
|
132
|
+
restrita = os.environ.get("TREASY_ENVIO_DIR", "").strip()
|
|
133
|
+
if restrita:
|
|
134
|
+
raiz = Path(restrita).resolve()
|
|
135
|
+
if not real.is_relative_to(raiz):
|
|
136
|
+
raise ArquivoRecusado("o arquivo está fora da pasta liberada em TREASY_ENVIO_DIR")
|
|
137
|
+
if real.stat().st_size > teto_envio():
|
|
138
|
+
raise ArquivoRecusado(f"o arquivo passa do teto de {teto_envio() // MB} MB")
|
|
139
|
+
return real
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class Arquivo:
|
|
144
|
+
parte: str
|
|
145
|
+
nome: str
|
|
146
|
+
dados: bytes = field(repr=False)
|
|
147
|
+
sha256: str
|
|
148
|
+
content_type: str
|
|
149
|
+
|
|
150
|
+
def resumo(self) -> dict:
|
|
151
|
+
return {"parte": self.parte, "nome": self.nome, "bytes": len(self.dados), "sha256": self.sha256}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def ler(parte: str, caminho: str) -> Arquivo:
|
|
155
|
+
"""Valida e lê UMA vez em binário; o sha256 sai destes bytes, que são os que sobem."""
|
|
156
|
+
real = validar_origem(caminho)
|
|
157
|
+
teto = teto_envio()
|
|
158
|
+
with open(real, "rb") as f:
|
|
159
|
+
dados = f.read(teto + 1)
|
|
160
|
+
if len(dados) > teto:
|
|
161
|
+
raise ArquivoRecusado(f"o arquivo passa do teto de {teto // MB} MB")
|
|
162
|
+
tipo = mimetypes.guess_type(real.name)[0] or "application/octet-stream"
|
|
163
|
+
return Arquivo(parte, real.name, dados, hashlib.sha256(dados).hexdigest(), tipo)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Envio ─────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class Envio:
|
|
170
|
+
partes: list[tuple[str, tuple]]
|
|
171
|
+
arquivos: list[Arquivo]
|
|
172
|
+
params: dict
|
|
173
|
+
contexto: str = ""
|
|
174
|
+
|
|
175
|
+
def resumo(self) -> list[dict]:
|
|
176
|
+
return [a.resumo() for a in self.arquivos]
|
|
177
|
+
|
|
178
|
+
def impressao(self) -> str:
|
|
179
|
+
return "|".join([self.contexto] + [f"{a.parte}:{a.sha256}" for a in self.arquivos])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_PENDENTES: dict[str, str] = {}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _chave_pendente(op: dict, path_params: Any, arquivos: dict) -> str:
|
|
186
|
+
return json.dumps([op["operation_id"], path_params or {}, arquivos], sort_keys=True, default=str)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def transporte_remoto() -> bool:
|
|
190
|
+
return os.environ.get("MCP_TRANSPORT", "stdio").strip().lower() in TRANSPORTES_REMOTOS
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def recusa_de_forma(op: dict, arquivos: Any, body: Any, campos: Any = None) -> dict | None:
|
|
194
|
+
"""O descompasso entre a forma da operação e o que veio, recusado antes de tudo."""
|
|
195
|
+
multipart = e_multipart(op)
|
|
196
|
+
arquivo_obrigatorio = any(p.get("obrigatorio") and p["tipo"] in ("arquivo", "arquivos")
|
|
197
|
+
for p in op.get("multipart_partes", []))
|
|
198
|
+
if (arquivos or campos) and not multipart:
|
|
199
|
+
return {"recusado": True, "motivo": "esta operação recebe JSON, não arquivo; use body",
|
|
200
|
+
"operation_id": op["operation_id"]}
|
|
201
|
+
if multipart and not arquivos and (arquivo_obrigatorio or not campos):
|
|
202
|
+
return {"recusado": True, "use": "arquivos",
|
|
203
|
+
"motivo": "esta operação recebe arquivo: passe o caminho completo em arquivos "
|
|
204
|
+
"({parte: caminho}) e os demais campos em campos",
|
|
205
|
+
"partes": op.get("multipart_partes", []), "operation_id": op["operation_id"]}
|
|
206
|
+
if multipart and body is not None:
|
|
207
|
+
return {"recusado": True, "use": "campos",
|
|
208
|
+
"motivo": "operação multipart: mande os campos em campos, não em body",
|
|
209
|
+
"operation_id": op["operation_id"]}
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def destino(client: Any, query_params: Any) -> dict:
|
|
214
|
+
"""Ambiente, schema, empresa e query: entram na impressão digital da confirmação."""
|
|
215
|
+
return {
|
|
216
|
+
"ambiente": getattr(client, "_ambiente", None),
|
|
217
|
+
"schema": getattr(client, "_schema", None),
|
|
218
|
+
"empresa": getattr(client, "_empresa_do_schema", None) or getattr(client, "_empresa_id", None),
|
|
219
|
+
"query_params": query_params or {},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def e_multipart(op: dict) -> bool:
|
|
224
|
+
return op.get("request_content_type") == "multipart/form-data"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def descricao_multipart(op: dict) -> dict:
|
|
228
|
+
"""O que o `descrever_operacao` acrescenta numa operação multipart (P2)."""
|
|
229
|
+
if not e_multipart(op):
|
|
230
|
+
return {}
|
|
231
|
+
return {
|
|
232
|
+
"request_content_type": "multipart/form-data",
|
|
233
|
+
"multipart_partes": op.get("multipart_partes", []),
|
|
234
|
+
"como_enviar": "as partes de tipo arquivo/arquivos recebem o CAMINHO completo do arquivo "
|
|
235
|
+
"local em `arquivos`; as demais vão em `campos` (objeto vira JSON)",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def preparar_envio(op: dict, arquivos: Any, campos: Any, path_params: Any,
|
|
240
|
+
ambiente: str | None, contexto: dict | None = None) -> Envio:
|
|
241
|
+
"""Monta as partes do multipart; levanta `ArquivoRecusado` antes de qualquer rede."""
|
|
242
|
+
if transporte_remoto():
|
|
243
|
+
raise ArquivoRecusado("este servidor não vê seu disco (transporte HTTP); use o MCP local")
|
|
244
|
+
if ambiente == "prod": # D2: envio em produção só depois do 003967
|
|
245
|
+
raise ArquivoRecusado("envio de arquivo em produção está desligado (decisão D2 do 003966)")
|
|
246
|
+
arquivos = {} if arquivos is None else arquivos
|
|
247
|
+
if not isinstance(arquivos, dict):
|
|
248
|
+
raise ArquivoRecusado("arquivos deve ser um objeto {parte: caminho | [caminhos]}")
|
|
249
|
+
campos = campos or {}
|
|
250
|
+
if not isinstance(campos, dict):
|
|
251
|
+
raise ArquivoRecusado("campos deve ser um objeto {parte: valor}")
|
|
252
|
+
por_nome = {p["nome"]: p for p in op.get("multipart_partes", [])}
|
|
253
|
+
partes: list[tuple[str, tuple]] = []
|
|
254
|
+
lidos: list[Arquivo] = []
|
|
255
|
+
for nome, valor in arquivos.items():
|
|
256
|
+
spec = por_nome.get(nome)
|
|
257
|
+
if not spec or spec["tipo"] not in ("arquivo", "arquivos"):
|
|
258
|
+
raise ArquivoRecusado(f"'{nome}' não é parte de arquivo desta operação")
|
|
259
|
+
caminhos = valor if isinstance(valor, list) else [valor]
|
|
260
|
+
if spec["tipo"] == "arquivo" and len(caminhos) != 1:
|
|
261
|
+
raise ArquivoRecusado(f"a parte '{nome}' recebe um arquivo só")
|
|
262
|
+
for caminho in caminhos:
|
|
263
|
+
arq = ler(nome, caminho)
|
|
264
|
+
lidos.append(arq)
|
|
265
|
+
if sum(len(x.dados) for x in lidos) > teto_envio(): # o teto vale pelo envio inteiro
|
|
266
|
+
raise ArquivoRecusado(f"os arquivos somados passam do teto de {teto_envio() // MB} MB")
|
|
267
|
+
partes.append((nome, (arq.nome, arq.dados, arq.content_type)))
|
|
268
|
+
for nome, valor in campos.items():
|
|
269
|
+
spec = por_nome.get(nome)
|
|
270
|
+
if not spec or spec["tipo"] in ("arquivo", "arquivos"):
|
|
271
|
+
raise ArquivoRecusado(f"'{nome}' não é campo desta operação")
|
|
272
|
+
if spec["tipo"] == "json":
|
|
273
|
+
partes.append((nome, (None, json.dumps(valor, ensure_ascii=False), "application/json")))
|
|
274
|
+
else:
|
|
275
|
+
partes.append((nome, (None, str(valor))))
|
|
276
|
+
for spec in por_nome.values():
|
|
277
|
+
if spec["nome"] in arquivos or spec["nome"] in campos:
|
|
278
|
+
continue
|
|
279
|
+
if spec.get("lista"): # P0: sem a lista de comentários o /salvar dá 500
|
|
280
|
+
partes.append((spec["nome"], (None, "[]", "application/json")))
|
|
281
|
+
elif spec.get("obrigatorio"):
|
|
282
|
+
raise ArquivoRecusado(f"falta a parte obrigatória '{spec['nome']}'")
|
|
283
|
+
# P0: o upload de anexo exige o id do caminho também como parâmetro da requisição.
|
|
284
|
+
params = {k: v for k, v in (path_params or {}).items() if isinstance(v, (str, int))}
|
|
285
|
+
impressao_destino = json.dumps([contexto or {}, campos], sort_keys=True, default=str)
|
|
286
|
+
return Envio(partes, lidos, params, impressao_destino)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def conferir_confirmacao(op: dict, path_params: Any, arquivos: dict, envio: Envio,
|
|
290
|
+
confirmar: bool) -> dict | None:
|
|
291
|
+
"""1ª chamada guarda a impressão digital; a confirmação só vale com os mesmos bytes."""
|
|
292
|
+
chave = _chave_pendente(op, path_params, arquivos or {})
|
|
293
|
+
if not confirmar:
|
|
294
|
+
_PENDENTES[chave] = envio.impressao()
|
|
295
|
+
return None
|
|
296
|
+
esperado = _PENDENTES.pop(chave, None)
|
|
297
|
+
if esperado is None:
|
|
298
|
+
return {"recusado": True, "motivo": "esta confirmação não tem pedido anterior; "
|
|
299
|
+
"chame sem confirmar_escrita para ver o resumo primeiro"}
|
|
300
|
+
if esperado != envio.impressao():
|
|
301
|
+
return {"recusado": True, "motivo": "o arquivo, os campos ou o destino mudaram depois do "
|
|
302
|
+
"resumo confirmado; nada foi enviado, peça a confirmação de novo"}
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def resumo_do_envio(client: Any, envio: Envio, campos: dict | None) -> dict:
|
|
307
|
+
"""O que o humano precisa ver para confirmar: onde vai, e quais bytes exatos."""
|
|
308
|
+
return {
|
|
309
|
+
"ambiente": getattr(client, "_ambiente", None),
|
|
310
|
+
"schema": getattr(client, "_schema", None),
|
|
311
|
+
"empresa": getattr(client, "_empresa_do_schema", None) or getattr(client, "_empresa_id", None),
|
|
312
|
+
"arquivos": envio.resumo(),
|
|
313
|
+
"campos": campos or {},
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── Download ──────────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
def nome_seguro(bruto: str | None, padrao: str = "arquivo") -> str:
|
|
320
|
+
"""O nome da API, saneado (D8): só o nome-base, sem controle, ':' nem nome reservado."""
|
|
321
|
+
nome = (bruto or "").replace("\\", "/").split("/")[-1]
|
|
322
|
+
nome = "".join(c for c in nome if unicodedata.category(c)[0] != "C")
|
|
323
|
+
nome = re.sub(r'[:*?"<>|]', "_", nome).strip(" .")
|
|
324
|
+
if not nome or set(nome) <= {"."}:
|
|
325
|
+
nome = padrao
|
|
326
|
+
if nome.split(".")[0].strip().lower() in NOMES_RESERVADOS:
|
|
327
|
+
nome = "_" + nome
|
|
328
|
+
if len(nome) > TETO_NOME: # curto o bastante para o MAX_PATH do Windows com o ".parcial"
|
|
329
|
+
raiz, ponto, ext = nome.rpartition(".")
|
|
330
|
+
nome = (raiz[: TETO_NOME - 10] + "." + ext) if ponto and len(ext) <= 8 else nome[:TETO_NOME]
|
|
331
|
+
return nome
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def nome_da_resposta(content_disposition: str | None) -> str | None:
|
|
335
|
+
if not content_disposition:
|
|
336
|
+
return None
|
|
337
|
+
achado = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', content_disposition, re.I)
|
|
338
|
+
return achado.group(1) if achado else None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def pasta_dos_arquivos(ambiente: str, schema: str) -> Path:
|
|
342
|
+
pasta = _exportacao.pasta_destino(ambiente, schema) / "arquivos"
|
|
343
|
+
motivo = _exportacao.destino_proibido(pasta)
|
|
344
|
+
if motivo:
|
|
345
|
+
raise ArquivoRecusado(f"a pasta de download está {motivo}; ajuste TREASY_EXPORTACAO_DIR")
|
|
346
|
+
pasta.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
347
|
+
return pasta
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _corpo_pequeno(primeiros: bytes, content_type: str) -> Any:
|
|
351
|
+
"""JSON ou texto de verdade volta como dado; o resto é arquivo (devolve None)."""
|
|
352
|
+
if content_type.startswith("text/"):
|
|
353
|
+
try:
|
|
354
|
+
return {"texto": primeiros.decode("utf-8")[:4096], "content_type": content_type}
|
|
355
|
+
except UnicodeDecodeError:
|
|
356
|
+
return None
|
|
357
|
+
try:
|
|
358
|
+
return json.loads(primeiros)
|
|
359
|
+
except (ValueError, UnicodeDecodeError):
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def gravar_stream(blocos: Any, pasta: Path, nome: str, teto: int, inicio: bytes = b"") -> dict:
|
|
364
|
+
"""Grava em ``.parcial`` e troca por ``os.replace``; acima do teto não sobra nenhum byte."""
|
|
365
|
+
stem, ponto, ext = nome.rpartition(".")
|
|
366
|
+
if not ponto or not stem:
|
|
367
|
+
stem, ext = nome, "bin"
|
|
368
|
+
final, _ = _exportacao._reservar(pasta, stem, ext, com_irmao=False)
|
|
369
|
+
parcial = final.with_name(final.name + ".parcial")
|
|
370
|
+
sha = hashlib.sha256()
|
|
371
|
+
total = 0
|
|
372
|
+
cabeca = b""
|
|
373
|
+
try:
|
|
374
|
+
fd = os.open(parcial, os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0), 0o600)
|
|
375
|
+
with os.fdopen(fd, "wb") as f:
|
|
376
|
+
for bloco in itertools.chain((inicio,), blocos):
|
|
377
|
+
if not bloco:
|
|
378
|
+
continue
|
|
379
|
+
total += len(bloco)
|
|
380
|
+
if total > teto:
|
|
381
|
+
raise ArquivoRecusado(f"o download passa do teto de {teto // MB} MB; nada foi gravado")
|
|
382
|
+
if len(cabeca) < 8:
|
|
383
|
+
cabeca += bloco[: 8 - len(cabeca)]
|
|
384
|
+
sha.update(bloco)
|
|
385
|
+
f.write(bloco)
|
|
386
|
+
aviso = None
|
|
387
|
+
if (cabeca.startswith(INICIOS_EXECUTAVEIS) or cabeca.lower().startswith(b"<script")
|
|
388
|
+
or ext.lower() in EXTENSOES_EXECUTAVEIS):
|
|
389
|
+
os.remove(final)
|
|
390
|
+
final, _ = _exportacao._reservar(pasta, f"{stem}.{ext}", "bin", com_irmao=False)
|
|
391
|
+
aviso = "conteúdo executável: gravado com extensão .bin; não abra sem conferir"
|
|
392
|
+
os.replace(parcial, final)
|
|
393
|
+
except BaseException:
|
|
394
|
+
for resto in (parcial, final):
|
|
395
|
+
try:
|
|
396
|
+
os.remove(resto)
|
|
397
|
+
except OSError:
|
|
398
|
+
pass
|
|
399
|
+
raise
|
|
400
|
+
return {"caminho": str(final), "bytes": total, "sha256": sha.hexdigest(), "aviso": aviso}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def processar_download(r: Any, pasta_de: Any, nome_padrao: str) -> Any:
|
|
404
|
+
"""Decide pela resposta real, não pela spec: anexo, binário ou JSON que não parseia vira arquivo."""
|
|
405
|
+
content_type = (r.headers.get("content-type") or "").split(";")[0].strip().lower()
|
|
406
|
+
disposicao = r.headers.get("content-disposition") or ""
|
|
407
|
+
blocos = r.iter_bytes(BLOCO)
|
|
408
|
+
inicio = b""
|
|
409
|
+
if "attachment" not in disposicao.lower() and (
|
|
410
|
+
content_type.startswith("text/") or "json" in content_type or not content_type
|
|
411
|
+
):
|
|
412
|
+
for bloco in blocos:
|
|
413
|
+
inicio += bloco
|
|
414
|
+
if len(inicio) > TETO_JSON_EM_MEMORIA:
|
|
415
|
+
break
|
|
416
|
+
if len(inicio) <= TETO_JSON_EM_MEMORIA:
|
|
417
|
+
if not inicio:
|
|
418
|
+
return {"success": True}
|
|
419
|
+
dado = _corpo_pequeno(inicio, content_type)
|
|
420
|
+
if dado is not None:
|
|
421
|
+
return dado
|
|
422
|
+
origem = nome_da_resposta(disposicao) or nome_padrao
|
|
423
|
+
gravado = gravar_stream(blocos, pasta_de(), nome_seguro(origem), teto_download(), inicio)
|
|
424
|
+
return {**gravado, "content_type": content_type or None, "nome_origem": origem[:200]}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def baixar(client: Any, real_path: str, query_params: dict | None, nome_padrao: str) -> Any:
|
|
428
|
+
if transporte_remoto():
|
|
429
|
+
raise ArquivoRecusado("este servidor (transporte HTTP) não grava arquivo no seu disco; "
|
|
430
|
+
"use o MCP local para baixar")
|
|
431
|
+
def pasta_de() -> Path:
|
|
432
|
+
schema = client._schema or ""
|
|
433
|
+
return pasta_dos_arquivos(client._ambiente, schema)
|
|
434
|
+
|
|
435
|
+
return client._baixar(real_path, query_params or None,
|
|
436
|
+
lambda r: processar_download(r, pasta_de, nome_padrao))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _segmento(valor: Any) -> str:
|
|
440
|
+
texto = str(valor)
|
|
441
|
+
if not texto or "/" in texto or "\\" in texto or ".." in texto or any(ord(c) < 32 for c in texto):
|
|
442
|
+
raise ArquivoRecusado("o nome da imagem atual veio fora do padrão")
|
|
443
|
+
return quote(texto, safe="")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def copia_anterior(client: Any, op: dict, campos: dict | None) -> dict | None:
|
|
447
|
+
"""D7: antes de trocar logo ou foto, baixa a imagem atual. Se a cópia falhar, a troca é recusada."""
|
|
448
|
+
if op.get("path") not in OPERACOES_DE_IMAGEM:
|
|
449
|
+
return None
|
|
450
|
+
original = "original" in op["path"]
|
|
451
|
+
try:
|
|
452
|
+
if op["path"].startswith("/empresas/"):
|
|
453
|
+
empresa = int((campos or {}).get("empresaId") or client._empresa_do_schema or client._empresa_id)
|
|
454
|
+
registro = client._get(f"/empresas/{empresa}")
|
|
455
|
+
atual = registro.get("logoOriginalUrl" if original else "logoUrl")
|
|
456
|
+
dono = (registro.get("cliente") or {}).get("id")
|
|
457
|
+
trecho = "logo_original" if original else "logo"
|
|
458
|
+
caminho = lambda: f"/empresas/{empresa}/{trecho}/{int(dono)}/{_segmento(atual)}" # noqa: E731
|
|
459
|
+
else:
|
|
460
|
+
registro = client._get("/usuarios/meus_dados")
|
|
461
|
+
atual = registro.get("fotoOriginalUrl" if original else "fotoUrl")
|
|
462
|
+
dono = registro.get("email")
|
|
463
|
+
trecho = "foto_original" if original else "foto"
|
|
464
|
+
caminho = lambda: f"/usuarios/{trecho}/{_segmento(dono)}/{_segmento(atual)}" # noqa: E731
|
|
465
|
+
if not atual or not dono:
|
|
466
|
+
return {"copia_anterior": None, "aviso": "não havia imagem anterior; nada a copiar"}
|
|
467
|
+
return {"copia_anterior": baixar(client, caminho(), None, str(atual))}
|
|
468
|
+
except Exception as e: # noqa: BLE001
|
|
469
|
+
raise ArquivoRecusado(
|
|
470
|
+
f"não consegui copiar a imagem atual, e sem a cópia a troca não tem volta: {str(e)[:120]}"
|
|
471
|
+
) from None
|