docling-processor / utils /validators.py
Gabriel Ramos
refactor: Melhorias de robustez
6c2e797
"""
Validadores para arquivos de entrada.
Este módulo contém funções para validar arquivos antes do processamento,
incluindo verificação de tamanho, contagem, MIME type e sanitização de nomes.
"""
import os
import re
from pathlib import Path
from typing import BinaryIO
import config
# Tenta importar python-magic, mas oferece fallback se não disponível
try:
import magic
HAS_MAGIC = True
except ImportError:
HAS_MAGIC = False
class ValidationError(Exception):
"""Exceção levantada quando uma validação falha."""
def __init__(self, message: str, error_code: str = "VALIDATION_ERROR"):
self.message = message
self.error_code = error_code
super().__init__(self.message)
def validate_file_count(files: list) -> bool:
"""
Valida se o número de arquivos está dentro do limite permitido.
Args:
files: Lista de arquivos para validar.
Returns:
True se a contagem está válida.
Raises:
ValidationError: Se houver arquivos demais ou nenhum arquivo.
"""
if not files:
raise ValidationError(
"Nenhum arquivo enviado. Por favor, selecione ao menos um arquivo.",
error_code="NO_FILES"
)
if len(files) > config.MAX_FILES_PER_SESSION:
raise ValidationError(
f"Muitos arquivos! Máximo permitido: {config.MAX_FILES_PER_SESSION}. "
f"Você enviou: {len(files)}.",
error_code="TOO_MANY_FILES"
)
return True
def validate_file_size(file_path: str | Path) -> bool:
"""
Valida se o tamanho do arquivo está dentro do limite permitido.
Args:
file_path: Caminho para o arquivo a ser validado.
Returns:
True se o tamanho está válido.
Raises:
ValidationError: Se o arquivo for muito grande.
"""
file_path = Path(file_path)
if not file_path.exists():
raise ValidationError(
f"Arquivo não encontrado: {file_path.name}",
error_code="FILE_NOT_FOUND"
)
file_size = file_path.stat().st_size
if file_size > config.MAX_FILE_SIZE_BYTES:
size_mb = file_size / (1024 * 1024)
raise ValidationError(
f"Arquivo muito grande: {file_path.name} ({size_mb:.1f}MB). "
f"Máximo permitido: {config.MAX_FILE_SIZE_MB}MB.",
error_code="FILE_TOO_LARGE"
)
if file_size == 0:
raise ValidationError(
f"Arquivo vazio: {file_path.name}",
error_code="EMPTY_FILE"
)
return True
def _get_mime_type_magic(file_path: str | Path) -> str:
"""
Obtém o MIME type usando python-magic.
Args:
file_path: Caminho para o arquivo.
Returns:
String com o MIME type detectado.
"""
mime = magic.Magic(mime=True)
return mime.from_file(str(file_path))
def _get_mime_type_fallback(file_path: str | Path) -> str:
"""
Fallback para detecção de MIME type sem python-magic.
Usa assinaturas de arquivo (magic bytes).
Args:
file_path: Caminho para o arquivo.
Returns:
String com o MIME type detectado ou extensão-based guess.
"""
file_path = Path(file_path)
# Magic bytes para tipos comuns
signatures = {
b"%PDF": "application/pdf",
b"PK\x03\x04": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
b"\xd0\xcf\x11\xe0": "application/msword", # OLE Compound Document
}
try:
with open(file_path, "rb") as f:
header = f.read(8)
for sig, mime_type in signatures.items():
if header.startswith(sig):
return mime_type
except Exception:
pass
# Fallback para extensão
ext = file_path.suffix.lower()
ext_to_mime = {
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
return ext_to_mime.get(ext, "application/octet-stream")
def get_mime_type(file_path: str | Path) -> str:
"""
Obtém o MIME type de um arquivo.
Usa python-magic se disponível, caso contrário usa fallback
baseado em assinaturas de arquivo.
Args:
file_path: Caminho para o arquivo.
Returns:
String com o MIME type detectado.
"""
if HAS_MAGIC:
try:
return _get_mime_type_magic(file_path)
except Exception:
# Fallback se libmagic falhar em runtime (OSError, etc)
pass
return _get_mime_type_fallback(file_path)
def validate_mime_type(file_path: str | Path) -> bool:
"""
Valida se o MIME type do arquivo é suportado.
Args:
file_path: Caminho para o arquivo a ser validado.
Returns:
True se o MIME type é válido.
Raises:
ValidationError: Se o tipo de arquivo não for suportado.
"""
file_path = Path(file_path)
extension = file_path.suffix.lower()
# Verifica se a extensão é suportada
if extension not in config.SUPPORTED_EXTENSIONS:
raise ValidationError(
f"Extensão não suportada: {extension}. "
f"Tipos aceitos: {', '.join(config.SUPPORTED_EXTENSIONS)}",
error_code="UNSUPPORTED_EXTENSION"
)
# Obtém o MIME type real do arquivo
detected_mime = get_mime_type(file_path)
# Verifica se o MIME type corresponde à extensão
expected_mimes = config.SUPPORTED_MIME_TYPES.get(extension, [])
if detected_mime not in expected_mimes:
# DOCX pode ser detectado como ZIP em alguns casos
if extension == ".docx" and detected_mime == "application/zip":
return True
raise ValidationError(
f"Tipo de arquivo inválido: {file_path.name}. "
f"O conteúdo não corresponde à extensão {extension}. "
f"Detectado: {detected_mime}",
error_code="MIME_MISMATCH"
)
return True
def sanitize_filename(filename: str) -> str:
"""
Remove caracteres especiais/perigosos do nome de arquivo.
Args:
filename: Nome original do arquivo.
Returns:
Nome de arquivo sanitizado.
"""
if not filename:
return "arquivo_sem_nome"
# Remove caracteres proibidos
for char in config.FORBIDDEN_FILENAME_CHARS:
filename = filename.replace(char, "_")
# Remove caracteres de controle
filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
# Substitui espaços múltiplos por um único underscore
filename = re.sub(r"\s+", "_", filename)
# Remove underscores múltiplos
filename = re.sub(r"_+", "_", filename)
# Remove underscores no início e fim
filename = filename.strip("_")
# Limita o comprimento
if len(filename) > config.FILENAME_MAX_LENGTH:
# Preserva a extensão
name, ext = os.path.splitext(filename)
max_name_len = config.FILENAME_MAX_LENGTH - len(ext)
filename = name[:max_name_len] + ext
# Se ficou vazio após sanitização
if not filename or filename == "." or filename == "..":
return "arquivo_sanitizado"
return filename
def validate_files(files: list) -> list[tuple[Path, str]]:
"""
Valida uma lista de arquivos completamente.
Args:
files: Lista de arquivos (podem ser paths ou objetos de arquivo).
Returns:
Lista de tuplas (path, nome_sanitizado) para arquivos válidos.
Raises:
ValidationError: Se qualquer validação falhar.
"""
validate_file_count(files)
validated = []
for file_obj in files:
# Gradio retorna objetos com atributo 'name'
if hasattr(file_obj, "name"):
file_path = Path(file_obj.name)
else:
file_path = Path(file_obj)
# Valida tamanho
validate_file_size(file_path)
# Valida MIME type
validate_mime_type(file_path)
# Sanitiza nome
sanitized_name = sanitize_filename(file_path.name)
validated.append((file_path, sanitized_name))
return validated